From 525d7d21be1b633a138ed0beec640669eebbbf76 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:11:16 +0200 Subject: [PATCH 01/16] fix(files): recover folder grant ownership --- .../AndroidFileSyncCapabilityLifecycle.kt | 468 ++++++++++++++ .../nextcloudnative/AndroidFileSyncEngine.kt | 35 +- .../AndroidFileSyncExecutionCoordination.kt | 30 +- .../AndroidFileSyncRootPicker.kt | 28 +- .../AndroidNextcloudServices.kt | 4 +- .../AndroidFileSyncCapabilityLifecycleTest.kt | 594 ++++++++++++++++++ .../android-file-sync-capability-lifecycle.md | 7 + .../app/FileOfflineCenterScreen.kt | 54 +- .../app/FileSyncRootLifecycle.kt | 20 + .../nextcloudnative/app/NextcloudPlatform.kt | 2 +- .../app/FileSyncRootLifecycleTest.kt | 19 + 11 files changed, 1206 insertions(+), 55 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt create mode 100644 changes/unreleased/android-file-sync-capability-lifecycle.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt new file mode 100644 index 000000000..f608a59ad --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -0,0 +1,468 @@ +package dev.obiente.nextcloudnative + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.Uri +import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import dev.obiente.nextcloudnative.app.FileSyncPair +import java.util.UUID +import org.json.JSONArray +import org.json.JSONObject + +internal enum class AndroidFileSyncCapabilityPhase { + Acquiring, + Ready, + Owned, + CleanupPending, +} + +internal data class AndroidFileSyncCapabilityRecord( + val id: String, + val uri: String, + val displayName: String, + val phase: AndroidFileSyncCapabilityPhase, + val processGeneration: String, + val preExistingReadGrant: Boolean, + val preExistingWriteGrant: Boolean, + val pairIds: Set = emptySet(), +) { + init { + UUID.fromString(id) + require(uri.startsWith("content://") && uri.length <= MAX_CAPABILITY_URI_CHARACTERS) + require(displayName.isNotBlank() && displayName.length <= MAX_CAPABILITY_DISPLAY_NAME_CHARACTERS) + UUID.fromString(processGeneration) + require(phase != AndroidFileSyncCapabilityPhase.Owned || pairIds.isNotEmpty()) + require(phase !in setOf( + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + ) || pairIds.isEmpty()) + pairIds.forEach(UUID::fromString) + } +} + +internal class AndroidFileSyncCapabilityRecoveryException(cause: Exception) : IllegalStateException( + "Saved folder access metadata is unavailable. No folder permissions were changed.", + cause, +) + +internal interface AndroidFileSyncCapabilityEncryptedStorage { + fun read(): String? + fun write(value: String): Boolean +} + +internal interface AndroidFileSyncCapabilityCipher { + fun encrypt(value: String): String + fun decrypt(value: String): String +} + +internal interface AndroidFileSyncGrantAccess { + fun exactGrant(uri: String): AndroidFileSyncGrantState + fun takeExactReadWriteGrant(uri: String) + fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) +} + +internal data class AndroidFileSyncGrantState(val read: Boolean, val write: Boolean) + +internal fun hasDuplicateAndroidFileSyncRoot( + pairs: List, + accountId: String, + localRootId: String, + remoteRootPath: String, +): Boolean = pairs.any { pair -> + pair.localRootId == localRootId && ( + localRootId.startsWith("content://") || + pair.accountId == accountId && pair.remoteRootPath == remoteRootPath + ) +} + +internal class AndroidFileSyncCapabilityStore( + private val storage: AndroidFileSyncCapabilityEncryptedStorage, + private val cipher: AndroidFileSyncCapabilityCipher, +) { + constructor(context: Context) : this( + SharedPreferencesFileSyncCapabilityStorage(context), + SessionFileSyncCapabilityCipher(), + ) + + fun list(): List = synchronized(LOCK) { readAll() } + + fun add(record: AndroidFileSyncCapabilityRecord) = synchronized(LOCK) { + val current = readAll() + require(current.none { it.id == record.id }) { "The folder capability ID is already in use." } + require(current.none { it.uri == record.uri }) { "That local folder is already selected." } + require(current.size < MAX_CAPABILITY_RECORDS) { "Too many local folders are awaiting setup." } + writeAll(current + record) + } + + fun replace( + id: String, + expected: AndroidFileSyncCapabilityPhase, + update: (AndroidFileSyncCapabilityRecord) -> AndroidFileSyncCapabilityRecord, + ): AndroidFileSyncCapabilityRecord = synchronized(LOCK) { + val current = readAll().toMutableList() + val index = current.indexOfFirst { it.id == id && it.phase == expected } + check(index >= 0) { "The folder capability changed before it could be updated." } + val updated = update(current[index]) + check(updated.id == id && updated.uri == current[index].uri) { + "Folder capability identity cannot change." + } + current[index] = updated + writeAll(current) + updated + } + + fun remove(id: String, expected: AndroidFileSyncCapabilityPhase) = synchronized(LOCK) { + val current = readAll() + check(current.any { it.id == id && it.phase == expected }) { + "The folder capability changed before it could be removed." + } + writeAll(current.filterNot { it.id == id }) + } + + private fun readAll(): List { + val encrypted = try { + storage.read() + } catch (failure: Exception) { + throw AndroidFileSyncCapabilityRecoveryException(failure) + } ?: return emptyList() + return try { + val array = JSONArray(cipher.decrypt(encrypted)) + check(array.length() <= MAX_CAPABILITY_RECORDS) { "Too many folder capabilities were saved." } + buildList { + repeat(array.length()) { index -> add(array.getJSONObject(index).toCapabilityRecord()) } + }.also { records -> + check(records.distinctBy(AndroidFileSyncCapabilityRecord::id).size == records.size) { + "Saved folder capability IDs are duplicated." + } + check(records.distinctBy(AndroidFileSyncCapabilityRecord::uri).size == records.size) { + "Saved folder capabilities are ambiguous." + } + } + } catch (failure: Exception) { + if (failure is AndroidFileSyncCapabilityRecoveryException) throw failure + throw AndroidFileSyncCapabilityRecoveryException(failure) + } + } + + private fun writeAll(records: List) { + val array = JSONArray() + records.forEach { array.put(it.toJson()) } + val encrypted = try { + cipher.encrypt(array.toString()) + } catch (failure: Exception) { + throw IllegalStateException("Folder capability recovery data could not be encrypted.", failure) + } + val saved = try { + storage.write(encrypted) + } catch (failure: Exception) { + throw IllegalStateException("Folder capability recovery data could not be saved.", failure) + } + check(saved) { "Folder capability recovery data could not be saved." } + } + + private companion object { + val LOCK = Any() + } +} + +internal class AndroidFileSyncCapabilityLifecycle internal constructor( + private val store: AndroidFileSyncCapabilityStore, + private val grants: AndroidFileSyncGrantAccess, + private val processGeneration: String, +) { + constructor(context: Context) : this( + AndroidFileSyncCapabilityStore(context.applicationContext), + ContentResolverFileSyncGrantAccess(context.applicationContext.contentResolver), + PROCESS_GENERATION, + ) + + fun acquire(exactUri: String, displayName: String): FileSyncLocalRoot = synchronized(LIFECYCLE_LOCK) { + val preExisting = grants.exactGrant(exactUri) + val record = AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), + uri = exactUri, + displayName = displayName, + phase = AndroidFileSyncCapabilityPhase.Acquiring, + processGeneration = processGeneration, + preExistingReadGrant = preExisting.read, + preExistingWriteGrant = preExisting.write, + ) + try { + store.add(record) + if (!preExisting.read || !preExisting.write) grants.takeExactReadWriteGrant(exactUri) + val acquired = grants.exactGrant(exactUri) + check(acquired.read && acquired.write) { + "The selected folder provider did not persist read and write access." + } + store.replace(record.id, AndroidFileSyncCapabilityPhase.Acquiring) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Ready) + } + FileSyncLocalRoot(exactUri, displayName) + } catch (failure: Exception) { + recoverAcquisition(record.id) + throw failure + } + } + + fun bindReady(localRootId: String, pairId: String) = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + it.uri == localRootId && it.phase == AndroidFileSyncCapabilityPhase.Ready + } ?: error("The selected local folder is no longer available.") + store.replace(record.id, AndroidFileSyncCapabilityPhase.Ready) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = setOf(pairId)) + } + } + + fun abandonSelection(localRootId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + it.uri == localRootId && + it.pairIds.isEmpty() && + it.phase in setOf( + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + ) + } ?: return@synchronized false + prepareAndFinishCleanup(record) + } + + fun abandonUncommittedPair(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.Owned + } ?: return@synchronized false + prepareAndFinishCleanup(record) + } + + fun preparePairCleanup(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { pairId in it.pairIds } + ?: return@synchronized false + when (record.phase) { + AndroidFileSyncCapabilityPhase.Owned -> { + store.replace(record.id, AndroidFileSyncCapabilityPhase.Owned) { + if (it.pairIds.size == 1) { + it.copy(phase = AndroidFileSyncCapabilityPhase.CleanupPending) + } else { + it.copy(pairIds = it.pairIds - pairId) + } + } + } + AndroidFileSyncCapabilityPhase.CleanupPending -> Unit + else -> error("The sync pair does not own its saved folder capability.") + } + true + } + + fun finishPairCleanup(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.CleanupPending + } ?: return@synchronized false + finishCleanup(record) + } + + fun reconcile(state: AndroidFileSyncPersistedState) = synchronized(LIFECYCLE_LOCK) { + var records = store.list() + val safPairs = state.coordinator.pairs.filter { it.localRootId.startsWith("content://") } + if (hasConflictingOwnership(records, safPairs)) return@synchronized + safPairs.groupBy(FileSyncPair::localRootId).forEach { (uri, matches) -> + if (records.none { it.uri == uri }) adoptLegacyCapability(uri, matches, state.localDisplayNames) + } + records = store.list() + records.forEach { original -> + val record = store.list().firstOrNull { it.id == original.id } ?: return@forEach + val matchingPairs = safPairs.filter { it.localRootId == record.uri } + val matchingIds = matchingPairs.mapTo(linkedSetOf(), FileSyncPair::id) + when (record.phase) { + AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.Acquiring, + -> if (record.processGeneration != processGeneration) { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) + } + } else { + prepareAndFinishCleanup(record) + } + } + AndroidFileSyncCapabilityPhase.Owned -> { + if (matchingIds.isNotEmpty() && matchingIds != record.pairIds) { + store.replace(record.id, record.phase) { it.copy(pairIds = matchingIds) } + } else if (matchingIds.isEmpty() && record.processGeneration != processGeneration) { + prepareAndFinishCleanup(record) + } + } + AndroidFileSyncCapabilityPhase.CleanupPending -> { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) + } + } else { + finishCleanup(record) + } + } + } + } + } + + private fun hasConflictingOwnership( + records: List, + pairs: List, + ): Boolean = records.any { record -> + record.pairIds.any { pairId -> pairs.any { it.id == pairId && it.localRootId != record.uri } } + } + + private fun adoptLegacyCapability( + uri: String, + pairs: List, + displayNames: Map, + ) { + val grant = grants.exactGrant(uri) + if (!grant.read && !grant.write) return + val pairIds = pairs.mapTo(linkedSetOf(), FileSyncPair::id) + val displayName = pairs.asSequence().mapNotNull { displayNames[it.id] }.firstOrNull() ?: "Selected folder" + store.add(AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), + uri = uri, + displayName = displayName, + phase = AndroidFileSyncCapabilityPhase.Owned, + processGeneration = processGeneration, + preExistingReadGrant = false, + preExistingWriteGrant = false, + pairIds = pairIds, + )) + } + + private fun recoverAcquisition(recordId: String) { + val record = try { + store.list().singleOrNull { it.id == recordId } + } catch (_: Exception) { + null + } ?: return + runCatching { prepareAndFinishCleanup(record) } + } + + private fun prepareAndFinishCleanup(record: AndroidFileSyncCapabilityRecord): Boolean { + val pending = when (record.phase) { + AndroidFileSyncCapabilityPhase.CleanupPending -> record + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.Owned, + -> store.replace(record.id, record.phase) { + it.copy(phase = AndroidFileSyncCapabilityPhase.CleanupPending) + } + } + return finishCleanup(pending) + } + + private fun finishCleanup(record: AndroidFileSyncCapabilityRecord): Boolean { + val ownedRead = !record.preExistingReadGrant + val ownedWrite = !record.preExistingWriteGrant + if (ownedRead || ownedWrite) { + val granted = try { + grants.exactGrant(record.uri) + } catch (_: Exception) { + return false + } + if ((ownedRead && granted.read) || (ownedWrite && granted.write)) { + try { + grants.releaseExactGrant(record.uri, ownedRead, ownedWrite) + } catch (_: Exception) { + return false + } + val retained = try { + grants.exactGrant(record.uri) + } catch (_: Exception) { + return false + } + if ((ownedRead && retained.read) || (ownedWrite && retained.write)) return false + if ((record.preExistingReadGrant && !retained.read) || + (record.preExistingWriteGrant && !retained.write) + ) return false + } + } + return try { + store.remove(record.id, AndroidFileSyncCapabilityPhase.CleanupPending) + true + } catch (_: Exception) { + false + } + } + + private companion object { + val LIFECYCLE_LOCK = Any() + val PROCESS_GENERATION: String = UUID.randomUUID().toString() + } +} + +private class SharedPreferencesFileSyncCapabilityStorage(context: Context) : + AndroidFileSyncCapabilityEncryptedStorage { + private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + override fun read(): String? = preferences.getString(KEY_RECORDS, null) + + override fun write(value: String): Boolean = preferences.edit().putString(KEY_RECORDS, value).commit() + + private companion object { + const val PREFERENCES = "nextcloud_native_file_sync_capabilities" + const val KEY_RECORDS = "records" + } +} + +private class SessionFileSyncCapabilityCipher : AndroidFileSyncCapabilityCipher { + private val delegate = SessionCipher() + + override fun encrypt(value: String): String = delegate.encrypt(value) + override fun decrypt(value: String): String = delegate.decrypt(value) +} + +private class ContentResolverFileSyncGrantAccess(private val resolver: ContentResolver) : + AndroidFileSyncGrantAccess { + override fun exactGrant(uri: String): AndroidFileSyncGrantState { + val target = Uri.parse(uri) + val exact = resolver.persistedUriPermissions.firstOrNull { it.uri == target } + return AndroidFileSyncGrantState(exact?.isReadPermission == true, exact?.isWritePermission == true) + } + + override fun takeExactReadWriteGrant(uri: String) { + resolver.takePersistableUriPermission(Uri.parse(uri), READ_WRITE_GRANT_FLAGS) + } + + override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { + resolver.releasePersistableUriPermission(Uri.parse(uri), grantFlags(read, write)) + } +} + +private fun AndroidFileSyncCapabilityRecord.toJson(): JSONObject = JSONObject() + .put("id", id) + .put("uri", uri) + .put("displayName", displayName) + .put("phase", phase.name) + .put("processGeneration", processGeneration) + .put("preExistingReadGrant", preExistingReadGrant) + .put("preExistingWriteGrant", preExistingWriteGrant) + .put("pairIds", JSONArray().also { array -> pairIds.sorted().forEach(array::put) }) + +private fun JSONObject.toCapabilityRecord(): AndroidFileSyncCapabilityRecord = AndroidFileSyncCapabilityRecord( + id = getString("id"), + uri = getString("uri"), + displayName = getString("displayName"), + phase = AndroidFileSyncCapabilityPhase.valueOf(getString("phase")), + processGeneration = getString("processGeneration"), + preExistingReadGrant = getBoolean("preExistingReadGrant"), + preExistingWriteGrant = getBoolean("preExistingWriteGrant"), + pairIds = when { + has("pairIds") -> getJSONArray("pairIds").let { array -> + buildSet { repeat(array.length()) { add(array.getString(it)) } } + } + !isNull("pairId") -> setOf(getString("pairId")) + else -> emptySet() + }, +) + +private const val READ_WRITE_GRANT_FLAGS = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION +private fun grantFlags(read: Boolean, write: Boolean): Int = + (if (read) Intent.FLAG_GRANT_READ_URI_PERMISSION else 0) or + (if (write) Intent.FLAG_GRANT_WRITE_URI_PERMISSION else 0) +private const val MAX_CAPABILITY_RECORDS = 64 +private const val MAX_CAPABILITY_URI_CHARACTERS = 8 * 1024 +private const val MAX_CAPABILITY_DISPLAY_NAME_CHARACTERS = 256 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 06cb6de16..f659e81b6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -91,7 +91,10 @@ internal class AndroidFileSyncEngine(context: Context) { private val scheduledMediaReconciliations = ConcurrentHashMap.newKeySet() private val scheduledPairScheduling = DeferredFileSyncPairSchedulingRegistry() private val stagingRoot = File(appContext.cacheDir, "file-sync-staging") - + private val capabilities = AndroidFileSyncCapabilityLifecycle(appContext) + init { + reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, store::load, capabilities) } + } suspend fun loadCenter( session: NextcloudSession, userId: String, @@ -257,14 +260,9 @@ internal class AndroidFileSyncEngine(context: Context) { val normalizedRemote = normalizeRemoteRoot(remoteRootPath) val accountId = NextcloudDocumentIds.accountKey(session) val current = store.load() - if (current.coordinator.pairs.any { - it.accountId == accountId && - it.localRootId == localRoot.localRootId && - it.remoteRootPath == normalizedRemote - } - ) { + if (hasDuplicateAndroidFileSyncRoot(current.coordinator.pairs, accountId, localRoot.localRootId, normalizedRemote)) { return@withLock FileSyncCenterActionResult.Rejected( - "That local and Nextcloud folder pair already exists.", + "That local folder already belongs to a folder sync pair.", ) } val pair = FileSyncPair( @@ -274,12 +272,17 @@ internal class AndroidFileSyncEngine(context: Context) { remoteRootPath = normalizedRemote, configuration = configuration, ) - store.save( - current.copy( + val ownsSafGrant = localRoot.localRootId.startsWith("content://") + if (ownsSafGrant) capabilities.bindReady(localRoot.localRootId, pair.id) + try { + store.save(current.copy( coordinator = addFileSyncPair(current.coordinator, pair), localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), - ), - ) + )) + } catch (failure: Exception) { + if (ownsSafGrant) recoverFailedFileSyncPairSave(pair.id, store::load, capabilities::abandonUncommittedPair) + throw failure + } scheduler.schedule(pair.id, accountId, userId, pair.configuration) FileSyncCenterActionResult.Completed("Folder sync pair added. Run it to review the first sync.") } @@ -312,8 +315,7 @@ internal class AndroidFileSyncEngine(context: Context) { "This folder sync pair belongs to another account.", ) } - val releasesLocalGrant = pair.localRootId.startsWith("content://") && - current.coordinator.pairs.none { it.id != pairId && it.localRootId == pair.localRootId } + capabilities.reconcile(current) var cleanedCoordinator: FileSyncCoordinatorState? = null var remoteCleanupRejected = false val removed = removeConfiguredFileSyncPair( @@ -351,6 +353,7 @@ internal class AndroidFileSyncEngine(context: Context) { } }, persistRemoval = { + capabilities.preparePairCleanup(pairId) val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) store.save( current.copy( @@ -360,9 +363,7 @@ internal class AndroidFileSyncEngine(context: Context) { ) }, cancelSchedule = { scheduler.cancel(pairId) }, - releaseLocalGrant = { - releaseSafGrantAfterPairRemoval(appContext, pair.localRootId, releasesLocalGrant) - }, + releaseLocalGrant = { capabilities.finishPairCleanup(pairId) }, ) if (!removed) { return@withLock FileSyncCenterActionResult.Rejected(if (remoteCleanupRejected) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index ed2e2125b..d56c74aee 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -98,6 +98,35 @@ internal fun deferFileSyncSnapshotActionUntilIdle( return job } +internal suspend fun reconcileFileSyncCapabilities( + lock: Mutex, + load: () -> AndroidFileSyncPersistedState, + capabilities: AndroidFileSyncCapabilityLifecycle, +) { + lock.withLock { + try { + capabilities.reconcile(load()) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + // Fail closed. A later process retries without releasing from incomplete metadata. + } + } +} + +internal fun recoverFailedFileSyncPairSave( + pairId: String, + load: () -> AndroidFileSyncPersistedState, + abandonUncommittedPair: (String) -> Unit, +) { + val commitIsAbsent = try { + load().coordinator.pairs.none { it.id == pairId } + } catch (_: Exception) { + false + } + if (commitIsAbsent) runCatching { abandonUncommittedPair(pairId) } +} + /** * Reads a complete atomic snapshot without waiting for active execution. * @@ -212,7 +241,6 @@ internal fun reconcileSafDownloadsBeforePairRemoval( false } } - internal fun releaseSafGrantAfterPairRemoval( context: Context, localRootId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index 81509c2f4..5fdaf1788 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative import android.content.ContentResolver import android.content.Context -import android.content.Intent import android.net.Uri import android.provider.DocumentsContract import androidx.activity.result.ActivityResultLauncher @@ -17,7 +16,10 @@ import kotlin.coroutines.resume * Only the selected tree receives a durable read/write grant. The sync engine never needs broad * storage access for SAF-backed pairs. */ -internal class AndroidFileSyncRootPicker(private val context: Context) { +internal class AndroidFileSyncRootPicker( + private val context: Context, + private val capabilities: AndroidFileSyncCapabilityLifecycle = AndroidFileSyncCapabilityLifecycle(context), +) { private var launcher: ActivityResultLauncher? = null private var pending: CancellableContinuation? = null @@ -45,15 +47,19 @@ internal class AndroidFileSyncRootPicker(private val context: Context) { continuation.resume(null) return } - val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val result = runCatching { - context.contentResolver.takePersistableUriPermission(uri, flags) - FileSyncLocalRoot(uri.toString(), queryDisplayName(context.contentResolver, uri)) + capabilities.acquire(uri.toString(), queryDisplayName(context.contentResolver, uri)) + } + result.onSuccess { localRoot -> + resumeFileSyncRootSelection(continuation, localRoot, capabilities::abandonSelection) } - result.onSuccess(continuation::resume) .onFailure { continuation.cancel(it) } } + fun abandon(localRootId: String) { + runCatching { capabilities.abandonSelection(localRootId) } + } + private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { val documentId = DocumentsContract.getTreeDocumentId(treeUri) val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) @@ -68,3 +74,13 @@ internal class AndroidFileSyncRootPicker(private val context: Context) { }.orEmpty().ifBlank { "Selected folder" } } } + +internal fun resumeFileSyncRootSelection( + continuation: CancellableContinuation, + localRoot: FileSyncLocalRoot, + abandon: (String) -> Unit, +) { + continuation.resume(localRoot) { _, undeliveredRoot, _ -> + runCatching { abandon(undeliveredRoot.localRootId) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..fe38b28bf 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1674,12 +1674,12 @@ internal class AndroidNextcloudServices( freedBytes = freed, ) } - override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = checkNotNull(fileSyncRootPicker) { "The native folder chooser is not available from this Android component." }.choose(initialRootHint) - + override fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = + fileSyncRootPicker?.abandon(localRoot.localRootId) ?: Unit override suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt new file mode 100644 index 000000000..59d09774a --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -0,0 +1,594 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncPair +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex + +class AndroidFileSyncCapabilityLifecycleTest { + @Test + fun `cancelled result delivery abandons the selected root`() { + val dispatcher = PausedDispatcher() + val scopeJob = Job() + var resumeSelection: (() -> Unit)? = null + var delivered = false + var abandoned: String? = null + val selectionJob = CoroutineScope(scopeJob + dispatcher).launch(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation -> + resumeSelection = { + resumeFileSyncRootSelection( + continuation, + dev.obiente.nextcloudnative.app.FileSyncLocalRoot(ROOT_URI, "Notes"), + abandon = { abandoned = it }, + ) + } + } + delivered = true + } + + checkNotNull(resumeSelection).invoke() + selectionJob.cancel() + dispatcher.runAll() + + assertTrue(selectionJob.isCancelled) + assertFalse(delivered) + assertEquals(ROOT_URI, abandoned) + scopeJob.cancel() + } + + @Test + fun `acquisition records intent before taking and ends ready`() { + val fixture = fixture() + + val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + + assertEquals(ROOT_URI, root.localRootId) + assertEquals(listOf("query", "take", "query"), fixture.grants.events) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + } + + @Test + fun `pre-existing exact grant is never taken or revoked`() { + val fixture = fixture(readGranted = true, writeGranted = true) + val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + + assertTrue(fixture.lifecycle.abandonSelection(root.localRootId)) + + assertEquals(listOf("query", "query"), fixture.grants.events) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `cleanup releases only the permission mode acquired for sync`() { + val fixture = fixture(readGranted = true) + val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + + assertTrue(fixture.lifecycle.abandonSelection(root.localRootId)) + + assertTrue(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertEquals(listOf(false to true), fixture.grants.releaseRequests) + } + + @Test + fun `grant inspection failure prevents acquisition`() { + val fixture = fixture() + fixture.grants.failQuery = true + + assertFailsWith { + fixture.lifecycle.acquire(ROOT_URI, "Notes") + } + + assertEquals(listOf("query"), fixture.grants.events) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `duplicate exact uri is rejected before a second grant is taken`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.grants.events.clear() + + assertFailsWith { + fixture.lifecycle.acquire(ROOT_URI, "Notes again") + } + + assertEquals(listOf("query"), fixture.grants.events) + assertEquals(1, fixture.store.list().size) + } + + @Test + fun `saf roots cannot be shared by a second pair`() { + assertTrue(hasDuplicateAndroidFileSyncRoot(listOf(pair()), "other-account", ROOT_URI, "Archive")) + } + + @Test + fun `non-saf roots retain the existing per-account destination rule`() { + val mediaPair = pair().copy(localRootId = "media-store://primary/DCIM/Camera") + + assertFalse( + hasDuplicateAndroidFileSyncRoot( + listOf(mediaPair), + mediaPair.accountId, + mediaPair.localRootId, + "Archive", + ), + ) + assertTrue( + hasDuplicateAndroidFileSyncRoot( + listOf(mediaPair), + mediaPair.accountId, + mediaPair.localRootId, + mediaPair.remoteRootPath, + ), + ) + } + + @Test + fun `failed ready persistence releases a newly acquired grant`() { + val fixture = fixture() + fixture.storage.failWriteNumber = 2 + + assertFailsWith { + fixture.lifecycle.acquire(ROOT_URI, "Notes") + } + + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `ambiguous acquiring commit cleans a possibly written record before take`() { + val fixture = fixture() + fixture.storage.failWriteNumber = 1 + fixture.storage.persistFailedWrite = true + + assertFailsWith { + fixture.lifecycle.acquire(ROOT_URI, "Notes") + } + + assertEquals(listOf("query", "query"), fixture.grants.events) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `repeated persistence failure retains acquiring evidence for restart`() { + val fixture = fixture() + fixture.storage.failWritesFrom = 2 + + assertFailsWith { + fixture.lifecycle.acquire(ROOT_URI, "Notes") + } + + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + val retained = fixture.store.list() + assertEquals(AndroidFileSyncCapabilityPhase.Acquiring, retained.single().phase) + } + + @Test + fun `pair cleanup is durable before release and retries a failed release`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + + assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + fixture.grants.failRelease = true + assertFalse(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + + fixture.grants.failRelease = false + assertTrue(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `prior process ready record is released when no pair owns it`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + + fixture.lifecycle.reconcile(state()) + + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `current process ready record remains available to the live setup ui`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(NEW_GENERATION) + + fixture.lifecycle.reconcile(state()) + + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + } + + @Test + fun `reselect before startup reconcile remains abandonable`() { + val fixture = fixture(generation = NEW_GENERATION, readGranted = true, writeGranted = true) + val selection = fixture.lifecycle.acquire(ROOT_URI, "Notes again") + + fixture.lifecycle.reconcile(state(pair())) + + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Ready, record.phase) + assertTrue(record.pairIds.isEmpty()) + assertTrue(fixture.lifecycle.abandonSelection(selection.localRootId)) + assertTrue(fixture.store.list().isEmpty()) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `restart binds a unique ready record to its committed pair`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + + fixture.lifecycle.reconcile(state(pair())) + + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(PAIR_ID), record.pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `cleanup pending returns to owned when pair deletion did not commit`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.CleanupPending) + + fixture.lifecycle.reconcile(state(pair())) + + assertEquals(AndroidFileSyncCapabilityPhase.Owned, fixture.store.list().single().phase) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `prior process owned record without a pair is cleaned`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Owned) + + fixture.lifecycle.reconcile(state()) + + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertTrue(fixture.store.list().isEmpty()) + } + + @Test + fun `unique legacy root is adopted before removal releases its grant`() { + val fixture = fixture(generation = NEW_GENERATION, readGranted = true, writeGranted = true) + + fixture.lifecycle.reconcile(state(pair())) + + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(PAIR_ID), record.pairIds) + assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) + assertTrue(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `read-only legacy grant is adopted and released on removal`() { + val fixture = fixture(generation = NEW_GENERATION, readGranted = true) + + fixture.lifecycle.reconcile(state(pair())) + assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) + assertTrue(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertEquals(listOf(true to true), fixture.grants.releaseRequests) + } + + @Test + fun `write-only legacy grant is adopted and released on removal`() { + val fixture = fixture(generation = NEW_GENERATION, writeGranted = true) + + fixture.lifecycle.reconcile(state(pair())) + assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) + assertTrue(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertEquals(listOf(true to true), fixture.grants.releaseRequests) + } + + @Test + fun `legacy shared roots are adopted and released after the last owner is removed`() { + val fixture = fixture(generation = NEW_GENERATION, readGranted = true, writeGranted = true) + + fixture.lifecycle.reconcile(state(pair(), pair(id = OTHER_PAIR_ID))) + + assertEquals(setOf(PAIR_ID, OTHER_PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) + assertFalse(fixture.lifecycle.finishPairCleanup(PAIR_ID)) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertTrue(fixture.lifecycle.preparePairCleanup(OTHER_PAIR_ID)) + assertTrue(fixture.lifecycle.finishPairCleanup(OTHER_PAIR_ID)) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `legacy duplicates adopt a ready grant without releasing it`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + + fixture.lifecycle.reconcile(state(pair(), pair(id = OTHER_PAIR_ID))) + + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(PAIR_ID, OTHER_PAIR_ID), record.pairIds) + } + + @Test + fun `same uri pair replaces a stale owner without releasing the live grant`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Owned) + + fixture.lifecycle.reconcile(state(pair(id = OTHER_PAIR_ID))) + + assertEquals(setOf(OTHER_PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertFalse("release" in fixture.grants.events) + } + + @Test + fun `owner id attached to another root fails closed`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Owned) + + fixture.lifecycle.reconcile(state(pair(localRootId = "content://example.documents/tree/other"))) + + assertEquals(setOf(PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertTrue(fixture.grants.events.isEmpty()) + } + + @Test + fun `unreadable capability state releases nothing`() { + val storage = FakeStorage("unreadable") + val grants = FakeGrantAccess(readGranted = true, writeGranted = true) + val store = AndroidFileSyncCapabilityStore(storage, ThrowingCipher) + val lifecycle = AndroidFileSyncCapabilityLifecycle(store, grants, NEW_GENERATION) + + assertFailsWith { + lifecycle.reconcile(state()) + } + + assertTrue(grants.readGranted) + assertTrue(grants.writeGranted) + assertTrue(grants.events.isEmpty()) + } + + @Test + fun `startup leaves grants unchanged when pair state is unreadable`() = runBlocking { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + fixture.grants.events.clear() + + reconcileFileSyncCapabilities( + lock = Mutex(), + load = { error("pair state unavailable") }, + capabilities = fixture.lifecycle, + ) + + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertTrue(fixture.grants.events.isEmpty()) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + } + + @Test + fun `failed pair save retains ownership when authoritative reload contains the pair`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + + recoverFailedFileSyncPairSave(PAIR_ID, { state(pair()) }, fixture.lifecycle::abandonUncommittedPair) + + assertEquals(setOf(PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `failed pair save releases ownership only when authoritative reload excludes the pair`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + + recoverFailedFileSyncPairSave(PAIR_ID, { state() }, fixture.lifecycle::abandonUncommittedPair) + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `failed pair save retains ownership when authoritative reload is unreadable`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + + recoverFailedFileSyncPairSave( + PAIR_ID, + load = { error("pair state unavailable") }, + abandonUncommittedPair = fixture.lifecycle::abandonUncommittedPair, + ) + + assertEquals(setOf(PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + private fun fixture( + generation: String = NEW_GENERATION, + readGranted: Boolean = false, + writeGranted: Boolean = false, + ): Fixture { + val storage = FakeStorage() + val store = AndroidFileSyncCapabilityStore(storage, IdentityCipher) + val grants = FakeGrantAccess(readGranted, writeGranted) + return Fixture(storage, store, grants, AndroidFileSyncCapabilityLifecycle(store, grants, generation)) + } + + private fun pair(id: String = PAIR_ID, localRootId: String = ROOT_URI) = FileSyncPair( + id = id, + accountId = "account", + localRootId = localRootId, + remoteRootPath = "Notes", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + + private fun state(vararg pairs: FileSyncPair) = AndroidFileSyncPersistedState( + coordinator = dev.obiente.nextcloudnative.app.FileSyncCoordinatorState(pairs.toList()), + localDisplayNames = pairs.associate { it.id to "Notes" }, + ) + + private data class Fixture( + val storage: FakeStorage, + val store: AndroidFileSyncCapabilityStore, + val grants: FakeGrantAccess, + val lifecycle: AndroidFileSyncCapabilityLifecycle, + ) { + fun seedReady(generation: String) { + store.add(record(generation, AndroidFileSyncCapabilityPhase.Ready)) + grants.readGranted = true + grants.writeGranted = true + } + + fun seedOwned(generation: String, phase: AndroidFileSyncCapabilityPhase) { + store.add(record(generation, phase, pairIds = setOf(PAIR_ID))) + grants.readGranted = true + grants.writeGranted = true + } + } + + private class FakeStorage(var value: String? = null) : AndroidFileSyncCapabilityEncryptedStorage { + var writes = 0 + var failWriteNumber: Int? = null + var failWritesFrom: Int? = null + var persistFailedWrite = false + + override fun read(): String? = value + + override fun write(value: String): Boolean { + writes += 1 + if (writes == failWriteNumber || writes >= (failWritesFrom ?: Int.MAX_VALUE)) { + if (persistFailedWrite) this.value = value + return false + } + this.value = value + return true + } + } + + private class FakeGrantAccess( + var readGranted: Boolean, + var writeGranted: Boolean, + ) : AndroidFileSyncGrantAccess { + var failQuery = false + var failRelease = false + val events = mutableListOf() + val releaseRequests = mutableListOf>() + + override fun exactGrant(uri: String): AndroidFileSyncGrantState { + events += "query" + if (failQuery) error("grant metadata unavailable") + return AndroidFileSyncGrantState(readGranted, writeGranted) + } + + override fun takeExactReadWriteGrant(uri: String) { + events += "take" + readGranted = true + writeGranted = true + } + + override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { + events += "release" + releaseRequests += read to write + if (failRelease) error("release failed") + if (read) readGranted = false + if (write) writeGranted = false + } + } + + private object IdentityCipher : AndroidFileSyncCapabilityCipher { + override fun encrypt(value: String): String = value + override fun decrypt(value: String): String = value + } + + private object ThrowingCipher : AndroidFileSyncCapabilityCipher { + override fun encrypt(value: String): String = error("not used") + override fun decrypt(value: String): String = error("cipher unavailable") + } + + private class PausedDispatcher : CoroutineDispatcher() { + private val tasks = ArrayDeque() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + tasks.addLast(block) + } + + fun runAll() { + while (tasks.isNotEmpty()) tasks.removeFirst().run() + } + } + + private companion object { + const val ROOT_URI = "content://example.documents/tree/notes" + val RECORD_ID: String = UUID.randomUUID().toString() + val PAIR_ID: String = UUID.randomUUID().toString() + val OTHER_PAIR_ID: String = UUID.randomUUID().toString() + val OLD_GENERATION: String = UUID.randomUUID().toString() + val NEW_GENERATION: String = UUID.randomUUID().toString() + + fun record( + generation: String, + phase: AndroidFileSyncCapabilityPhase, + pairIds: Set = emptySet(), + ) = AndroidFileSyncCapabilityRecord( + id = RECORD_ID, + uri = ROOT_URI, + displayName = "Notes", + phase = phase, + processGeneration = generation, + preExistingReadGrant = false, + preExistingWriteGrant = false, + pairIds = pairIds, + ) + } +} diff --git a/changes/unreleased/android-file-sync-capability-lifecycle.md b/changes/unreleased/android-file-sync-capability-lifecycle.md new file mode 100644 index 000000000..4304e7655 --- /dev/null +++ b/changes/unreleased/android-file-sync-capability-lifecycle.md @@ -0,0 +1,7 @@ +category: fix +issue: 11 +pull: none +platforms: android +user-facing: yes + +Android folder sync now tracks selected-folder access through setup, pairing, removal, and restart recovery so cancelled setup cannot leave access behind and removal retries a failed permission release. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index d39deeed3..c2f272671 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt @@ -99,27 +99,25 @@ internal fun FileOfflineCenterScreen( var mediaFolderDiscovery by remember(session, userId) { mutableStateOf(null) } var mediaDiscoveryLoading by remember(session, userId) { mutableStateOf(false) } var syncBusyPairIds by remember(session, userId) { mutableStateOf>(emptySet()) } - var pendingLocalRootJson by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(null) + val pendingLocalRootState = remember(session, userId) { + mutableStateOf(null) } - var pendingMediaSuggestionJson by rememberSaveable(session.serverUrl, session.loginName, userId) { + var pendingLocalRoot by pendingLocalRootState + var pendingMediaSuggestionJson by remember(session, userId) { mutableStateOf(null) } - var pendingRemotePath by rememberSaveable(session.serverUrl, session.loginName, userId) { + var pendingRemotePath by remember(session, userId) { mutableStateOf(null) } - var pendingSyncConfigurationJson by rememberSaveable(session.serverUrl, session.loginName, userId) { + var pendingSyncConfigurationJson by remember(session, userId) { mutableStateOf(null) } - var remoteFolderPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { + var remoteFolderPickerVisible by remember(session, userId) { mutableStateOf(false) } - var syncSelectionPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { + var syncSelectionPickerVisible by remember(session, userId) { mutableStateOf(false) } - val pendingLocalRoot = pendingLocalRootJson?.let { encoded -> - runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() - } val pendingMediaSuggestion = pendingMediaSuggestionJson?.let { encoded -> runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() } @@ -151,7 +149,16 @@ internal fun FileOfflineCenterScreen( var virtualFolderPickerError by remember(session, userId) { mutableStateOf(null) } var releaseVirtualFolderPath by remember(session, userId) { mutableStateOf(null) } val scope = rememberCoroutineScope() - + fun abandonPendingFolderSync() { + pendingLocalRoot?.let(services::abandonFileSyncLocalRoot) + pendingLocalRoot = null + pendingMediaSuggestionJson = null + pendingRemotePath = null + pendingSyncConfigurationJson = null + pendingMediaPreview = null + syncSelectionPickerVisible = false + } + AbandonFileSyncRootOnDispose(services, pendingLocalRootState) fun runItemAction(item: FileOfflineCenterItem, remove: Boolean) { if (actionKey != null) return actionKey = item.key @@ -202,7 +209,7 @@ internal fun FileOfflineCenterScreen( runCatching { services.chooseFileSyncLocalRoot() } .onSuccess { selected -> pendingMediaSuggestionJson = null - pendingLocalRootJson = selected?.let { fileSyncSetupJson.encodeToString(it) } + pendingLocalRoot = selected pendingRemotePath = selected?.let { "" } pendingSyncConfigurationJson = selected ?.let { defaultFileSyncConfiguration(isMediaSuggestion = false) } @@ -223,7 +230,7 @@ internal fun FileOfflineCenterScreen( pendingMediaPreview = null mediaPreviewError = null pendingMediaSuggestionJson = fileSyncSetupJson.encodeToString(suggestion) - pendingLocalRootJson = fileSyncSetupJson.encodeToString(suggestion.localRoot) + pendingLocalRoot = suggestion.localRoot pendingRemotePath = suggestion.suggestedRemoteRootPath pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString( defaultFileSyncConfiguration(isMediaSuggestion = true), @@ -1005,9 +1012,7 @@ internal fun FileOfflineCenterScreen( onDismiss = { remoteFolderPickerVisible = false if (pendingRemotePath == null) { - pendingLocalRootJson = null - pendingMediaSuggestionJson = null - pendingSyncConfigurationJson = null + abandonPendingFolderSync() } }, onSelected = { selectedPath -> @@ -1064,12 +1069,7 @@ internal fun FileOfflineCenterScreen( busy = ADD_PAIR_BUSY_ID in syncBusyPairIds, onDismiss = { if (ADD_PAIR_BUSY_ID !in syncBusyPairIds) { - pendingLocalRootJson = null - pendingMediaSuggestionJson = null - pendingRemotePath = null - pendingSyncConfigurationJson = null - pendingMediaPreview = null - syncSelectionPickerVisible = false + abandonPendingFolderSync() } }, onChooseDestination = { @@ -1097,15 +1097,13 @@ internal fun FileOfflineCenterScreen( }.onSuccess { result -> actionMessage = result.fileSyncCenterMessage() if (result is FileSyncCenterActionResult.Completed) { - pendingLocalRootJson = null - pendingMediaSuggestionJson = null - pendingRemotePath = null - pendingSyncConfigurationJson = null - pendingMediaPreview = null - syncSelectionPickerVisible = false + abandonPendingFolderSync() refreshAttempt += 1 + } else { + abandonPendingFolderSync() } }.onFailure { failure -> + abandonPendingFolderSync() actionMessage = failure.message ?: "Could not add this folder sync pair." } syncBusyPairIds -= ADD_PAIR_BUSY_ID diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt new file mode 100644 index 000000000..c123e2963 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt @@ -0,0 +1,20 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State + +@Composable +internal fun AbandonFileSyncRootOnDispose( + services: NextcloudPlatformServices, + localRoot: State, +) { + DisposableEffect(services, localRoot) { + onDispose(fileSyncRootDisposal({ localRoot.value }, services::abandonFileSyncLocalRoot)) + } +} + +internal fun fileSyncRootDisposal( + currentRoot: () -> FileSyncLocalRoot?, + abandon: (FileSyncLocalRoot) -> Unit, +): () -> Unit = { currentRoot()?.let(abandon) } 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 27e346602..71abbf984 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -935,7 +935,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa /** Opens the native folder chooser and persists a least-privilege folder grant. */ suspend fun chooseFileSyncLocalRoot(initialRootHint: String? = null): FileSyncLocalRoot? = null - + fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = Unit /** Lists durable share-sheet uploads that still need progress or user review. */ suspend fun loadIncomingShareRecoveries( session: NextcloudSession, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt new file mode 100644 index 000000000..cd27df179 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt @@ -0,0 +1,19 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals + +class FileSyncRootLifecycleTest { + @Test + fun `delivery followed by disposal before recomposition abandons the delivered root`() { + var pendingRoot: FileSyncLocalRoot? = null + val abandoned = mutableListOf() + val dispose = fileSyncRootDisposal({ pendingRoot }, abandoned::add) + val deliveredRoot = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + + pendingRoot = deliveredRoot + dispose() + + assertEquals(listOf(deliveredRoot), abandoned) + } +} From 29b1ea8af33eb5e9bcb6eef5d2df5b86dd228e87 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:01:42 +0200 Subject: [PATCH 02/16] fix(accounts): retire folder grant ownership --- .../AndroidFileSyncExecutionCoordination.kt | 44 +--- ...FileSyncAccountRetirementCapabilityTest.kt | 198 ++++++++++++++++++ .../AndroidFileSyncEngineInvariantTest.kt | 34 +-- 3 files changed, 225 insertions(+), 51 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index d56c74aee..c24226625 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -1,7 +1,6 @@ package dev.obiente.nextcloudnative import android.content.Context -import android.content.Intent import android.net.Uri import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation @@ -241,37 +240,18 @@ internal fun reconcileSafDownloadsBeforePairRemoval( false } } -internal fun releaseSafGrantAfterPairRemoval( - context: Context, - localRootId: String, - releasesLocalGrant: Boolean, -) { - if (!releasesLocalGrant) return - try { - context.contentResolver.releasePersistableUriPermission( - Uri.parse(localRootId), - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - ) - } catch (failure: CancellationException) { - throw failure - } catch (_: Exception) { - // The pair is gone, so a later picker can release or replace this stale grant. - } -} - internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String) { AndroidFileSyncEngine.ENGINE_LOCK.withLock { val store = AndroidFileSyncStore(context) val current = store.load() - val (retiredPairs, retainedPairs) = current.coordinator.pairs.partition { pair -> - pair.accountId == accountId - } + val retiredPairs = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } if (retiredPairs.isEmpty()) return@withLock + val capabilities = AndroidFileSyncCapabilityLifecycle(context) + capabilities.reconcile(current) val scheduler = AndroidFileSyncScheduler(context) val notifications = AndroidNotificationCoordinator(context) retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = retainedPairs, reconcileLocalDownloads = { pair -> reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) }, @@ -279,22 +259,21 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account cancelNotification = { pair -> notifications.cancel(pair.accountId, androidFileSyncNotificationId(pair.id)) }, + prepareLocalGrantCleanup = capabilities::preparePairCleanup, persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, - releaseLocalGrant = { localRootId -> - releaseSafGrantAfterPairRemoval(context, localRootId, releasesLocalGrant = true) - }, + finishLocalGrantCleanup = capabilities::finishPairCleanup, ) } } internal suspend fun retireConfiguredFileSyncAccountPairs( retiredPairs: List, - retainedPairs: List, reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, cancelSchedule: suspend (FileSyncPair) -> Unit, cancelNotification: suspend (FileSyncPair) -> Unit, + prepareLocalGrantCleanup: suspend (String) -> Unit, persistRetirement: suspend () -> Unit, - releaseLocalGrant: suspend (String) -> Unit, + finishLocalGrantCleanup: suspend (String) -> Unit, ) { retiredPairs.forEach { pair -> check(reconcileLocalDownloads(pair)) { @@ -308,15 +287,10 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( } currentCoroutineContext().ensureActive() - val retainedLocalRoots = retainedPairs.mapTo(hashSetOf()) { pair -> pair.localRootId } - val releasedLocalRoots = retiredPairs.asSequence() - .map { pair -> pair.localRootId } - .filter { localRootId -> localRootId.startsWith("content://") && localRootId !in retainedLocalRoots } - .distinct() - .toList() withContext(NonCancellable) { - releasedLocalRoots.forEach { localRootId -> releaseLocalGrant(localRootId) } + retiredPairs.forEach { pair -> prepareLocalGrantCleanup(pair.id) } persistRetirement() + retiredPairs.forEach { pair -> finishLocalGrantCleanup(pair.id) } } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt new file mode 100644 index 000000000..e75d07997 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt @@ -0,0 +1,198 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState +import dev.obiente.nextcloudnative.app.FileSyncPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AndroidFileSyncAccountRetirementCapabilityTest { + @Test + fun `duplicate legacy roots release once after every retired owner is persisted`() = runBlocking { + val fixture = fixture() + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT), pair(SECOND_PAIR_ID, REMOVED_ACCOUNT)) + fixture.lifecycle.reconcile(state(retired)) + + retire(fixture.lifecycle, retired) { Unit } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertEquals(1, fixture.grants.releaseCount) + } + + @Test + fun `retained account owner keeps a shared legacy root grant`() = runBlocking { + val fixture = fixture() + val retired = pair(FIRST_PAIR_ID, REMOVED_ACCOUNT) + val retained = pair(SECOND_PAIR_ID, RETAINED_ACCOUNT) + fixture.lifecycle.reconcile(state(listOf(retired, retained))) + + retire(fixture.lifecycle, listOf(retired)) { Unit } + + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(SECOND_PAIR_ID), record.pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertEquals(0, fixture.grants.releaseCount) + } + + @Test + fun `successful retirement persists cleanup before releasing the grant`() = runBlocking { + val fixture = fixture() + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + fixture.lifecycle.reconcile(state(retired)) + val events = mutableListOf() + + retireConfiguredFileSyncAccountPairs( + retiredPairs = retired, + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + prepareLocalGrantCleanup = { pairId -> + events += "prepare-$pairId" + fixture.lifecycle.preparePairCleanup(pairId) + }, + persistRetirement = { + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + assertTrue(fixture.grants.readGranted) + events += "persist" + }, + finishLocalGrantCleanup = { pairId -> + events += "finish-$pairId" + fixture.lifecycle.finishPairCleanup(pairId) + }, + ) + + assertEquals(listOf("prepare-$FIRST_PAIR_ID", "persist", "finish-$FIRST_PAIR_ID"), events) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `failed precommit save restores ownership from the authoritative pair on restart`() = runBlocking { + val fixture = fixture(OLD_GENERATION) + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + val authoritative = state(retired) + fixture.lifecycle.reconcile(authoritative) + + assertFailsWith { + retire(fixture.lifecycle, retired) { error("save failed before commit") } + } + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + + restarted(fixture).reconcile(authoritative) + + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(FIRST_PAIR_ID), record.pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `failed postcommit save releases from authoritative removal on restart`() = runBlocking { + val fixture = fixture(OLD_GENERATION) + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + fixture.lifecycle.reconcile(state(retired)) + + assertFailsWith { + retire(fixture.lifecycle, retired) { error("save reported failure after commit") } + } + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + + restarted(fixture).reconcile(state(emptyList())) + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + private suspend fun retire( + lifecycle: AndroidFileSyncCapabilityLifecycle, + retiredPairs: List, + persist: suspend () -> Unit, + ) { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + prepareLocalGrantCleanup = { pairId -> lifecycle.preparePairCleanup(pairId) }, + persistRetirement = persist, + finishLocalGrantCleanup = { pairId -> lifecycle.finishPairCleanup(pairId) }, + ) + } + + private fun fixture(generation: String = NEW_GENERATION): Fixture { + val store = AndroidFileSyncCapabilityStore(MemoryStorage(), IdentityCipher) + val grants = GrantAccess() + return Fixture(store, grants, AndroidFileSyncCapabilityLifecycle(store, grants, generation)) + } + + private fun restarted(fixture: Fixture) = + AndroidFileSyncCapabilityLifecycle(fixture.store, fixture.grants, NEW_GENERATION) + + private fun pair(id: String, accountId: String) = FileSyncPair( + id = id, + accountId = accountId, + localRootId = ROOT_URI, + remoteRootPath = "Notes", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + + private fun state(pairs: List) = AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(pairs), + localDisplayNames = pairs.associate { it.id to "Notes" }, + ) + + private data class Fixture( + val store: AndroidFileSyncCapabilityStore, + val grants: GrantAccess, + val lifecycle: AndroidFileSyncCapabilityLifecycle, + ) + + private class MemoryStorage : AndroidFileSyncCapabilityEncryptedStorage { + private var value: String? = null + override fun read(): String? = value + override fun write(value: String): Boolean { + this.value = value + return true + } + } + + private class GrantAccess : AndroidFileSyncGrantAccess { + var readGranted = true + var writeGranted = true + var releaseCount = 0 + + override fun exactGrant(uri: String) = AndroidFileSyncGrantState(readGranted, writeGranted) + override fun takeExactReadWriteGrant(uri: String) = error("Legacy adoption must not take a grant") + override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { + releaseCount += 1 + if (read) readGranted = false + if (write) writeGranted = false + } + } + + private object IdentityCipher : AndroidFileSyncCapabilityCipher { + override fun encrypt(value: String): String = value + override fun decrypt(value: String): String = value + } + + private companion object { + const val ROOT_URI = "content://example.documents/tree/notes" + const val REMOVED_ACCOUNT = "removed-account" + const val RETAINED_ACCOUNT = "retained-account" + const val FIRST_PAIR_ID = "10000000-0000-0000-0000-000000000001" + const val SECOND_PAIR_ID = "10000000-0000-0000-0000-000000000002" + const val OLD_GENERATION = "20000000-0000-0000-0000-000000000001" + const val NEW_GENERATION = "20000000-0000-0000-0000-000000000002" + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index dcaccc5d1..5fea9e38e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -545,25 +545,22 @@ class AndroidFileSyncEngineInvariantTest { } @Test - fun accountRetirementReconcilesBeforePersistingAndReleasesOnlyUnsharedSafGrants() = runBlocking { - val sharedRoot = "content://documents/shared" - val retiredRoot = "content://documents/retired" + fun accountRetirementPreparesAllGrantsBeforePersistingAndFinishesAfter() = runBlocking { val retiredPairs = listOf( - fileSyncPair("retired-a", "removed-account", sharedRoot), - fileSyncPair("retired-b", "removed-account", retiredRoot), - fileSyncPair("retired-c", "removed-account", retiredRoot), + fileSyncPair("retired-a", "removed-account", "content://documents/shared"), + fileSyncPair("retired-b", "removed-account", "content://documents/retired"), + fileSyncPair("retired-c", "removed-account", "content://documents/retired"), ) - val retainedPairs = listOf(fileSyncPair("retained", "retained-account", sharedRoot)) val events = mutableListOf() retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = retainedPairs, reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; true }, cancelSchedule = { pair -> events += "cancel-${pair.id}" }, cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + prepareLocalGrantCleanup = { pairId -> events += "prepare-$pairId" }, persistRetirement = { events += "persist-retirement" }, - releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + finishLocalGrantCleanup = { pairId -> events += "finish-$pairId" }, ) assertEquals( @@ -577,8 +574,13 @@ class AndroidFileSyncEngineInvariantTest { "cancel-notification-retired-b", "cancel-retired-c", "cancel-notification-retired-c", - "release-$retiredRoot", + "prepare-retired-a", + "prepare-retired-b", + "prepare-retired-c", "persist-retirement", + "finish-retired-a", + "finish-retired-b", + "finish-retired-c", ), events, ) @@ -595,12 +597,12 @@ class AndroidFileSyncEngineInvariantTest { assertFailsWith { retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = emptyList(), reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; pair.id == "retired-a" }, cancelSchedule = { pair -> events += "cancel-${pair.id}" }, cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + prepareLocalGrantCleanup = { pairId -> events += "prepare-$pairId" }, persistRetirement = { events += "persist-retirement" }, - releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + finishLocalGrantCleanup = { pairId -> events += "finish-$pairId" }, ) } @@ -618,15 +620,15 @@ class AndroidFileSyncEngineInvariantTest { assertFailsWith { retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = emptyList(), reconcileLocalDownloads = { true }, cancelSchedule = { pair -> events += "cancel-${pair.id}" if (pair.id == "pair-b") error("synthetic WorkManager cancellation failure") }, cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + prepareLocalGrantCleanup = { pairId -> events += "prepare-$pairId" }, persistRetirement = { events += "persist-retirement" }, - releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + finishLocalGrantCleanup = { pairId -> events += "finish-$pairId" }, ) } @@ -641,15 +643,15 @@ class AndroidFileSyncEngineInvariantTest { assertFailsWith { retireConfiguredFileSyncAccountPairs( retiredPairs = listOf(pair), - retainedPairs = emptyList(), reconcileLocalDownloads = { true }, cancelSchedule = { events += "cancel-schedule" }, cancelNotification = { events += "cancel-notification" error("synthetic notification cancellation failure") }, + prepareLocalGrantCleanup = { events += "prepare-grant" }, persistRetirement = { events += "persist-retirement" }, - releaseLocalGrant = { events += "release-grant" }, + finishLocalGrantCleanup = { events += "finish-grant" }, ) } From d475081d4987034f262961fe2d07b01220f13028 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:31:06 +0200 Subject: [PATCH 03/16] fix(files): reconcile ambiguous grant removal --- .../AndroidFileSyncCapabilityLifecycle.kt | 19 +++++++++++++++++++ .../nextcloudnative/AndroidFileSyncEngine.kt | 12 +++++------- .../AndroidFileSyncExecutionCoordination.kt | 14 +++++++++++--- ...FileSyncAccountRetirementCapabilityTest.kt | 19 +++++++++++++++++++ .../AndroidFileSyncCapabilityLifecycleTest.kt | 18 ++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt index f608a59ad..0c5effd99 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -259,6 +259,25 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( finishCleanup(record) } + fun persistPairRemoval( + load: () -> AndroidFileSyncPersistedState, + persist: () -> Unit, + ) = try { + persist() + } catch (failure: Exception) { + recoverAmbiguousPairRemoval(load) + throw failure + } + + private fun recoverAmbiguousPairRemoval(load: () -> AndroidFileSyncPersistedState) = synchronized(LIFECYCLE_LOCK) { + val authoritative = try { + load() + } catch (_: Exception) { + return@synchronized + } + runCatching { reconcile(authoritative) } + } + fun reconcile(state: AndroidFileSyncPersistedState) = synchronized(LIFECYCLE_LOCK) { var records = store.list() val safPairs = state.coordinator.pairs.filter { it.localRootId.startsWith("content://") } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index f659e81b6..aa005ba8b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -92,9 +92,7 @@ internal class AndroidFileSyncEngine(context: Context) { private val scheduledPairScheduling = DeferredFileSyncPairSchedulingRegistry() private val stagingRoot = File(appContext.cacheDir, "file-sync-staging") private val capabilities = AndroidFileSyncCapabilityLifecycle(appContext) - init { - reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, store::load, capabilities) } - } + init { reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, store::load, capabilities) } } suspend fun loadCenter( session: NextcloudSession, userId: String, @@ -355,12 +353,12 @@ internal class AndroidFileSyncEngine(context: Context) { persistRemoval = { capabilities.preparePairCleanup(pairId) val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) - store.save( - current.copy( + capabilities.persistPairRemoval(store::load) { + store.save(current.copy( coordinator = remaining, localDisplayNames = current.localDisplayNames - pairId, - ), - ) + )) + } }, cancelSchedule = { scheduler.cancel(pairId) }, releaseLocalGrant = { capabilities.finishPairCleanup(pairId) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index c24226625..d0fc19cd5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -244,10 +244,9 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account AndroidFileSyncEngine.ENGINE_LOCK.withLock { val store = AndroidFileSyncStore(context) val current = store.load() - val retiredPairs = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } - if (retiredPairs.isEmpty()) return@withLock val capabilities = AndroidFileSyncCapabilityLifecycle(context) - capabilities.reconcile(current) + val retiredPairs = reconcileAndroidFileSyncAccountRetirement(current, accountId, capabilities) + if (retiredPairs.isEmpty()) return@withLock val scheduler = AndroidFileSyncScheduler(context) val notifications = AndroidNotificationCoordinator(context) retireConfiguredFileSyncAccountPairs( @@ -266,6 +265,15 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account } } +internal fun reconcileAndroidFileSyncAccountRetirement( + state: AndroidFileSyncPersistedState, + accountId: String, + capabilities: AndroidFileSyncCapabilityLifecycle, +): List { + capabilities.reconcile(state) + return state.coordinator.pairs.filter { pair -> pair.accountId == accountId } +} + internal suspend fun retireConfiguredFileSyncAccountPairs( retiredPairs: List, reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt index e75d07997..59fbdf61e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt @@ -114,6 +114,25 @@ class AndroidFileSyncAccountRetirementCapabilityTest { assertFalse(fixture.grants.writeGranted) } + @Test + fun `empty account retirement retry still reconciles committed capability cleanup`() { + val fixture = fixture() + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + fixture.lifecycle.reconcile(state(retired)) + fixture.lifecycle.preparePairCleanup(FIRST_PAIR_ID) + + val remaining = reconcileAndroidFileSyncAccountRetirement( + state(emptyList()), + REMOVED_ACCOUNT, + fixture.lifecycle, + ) + + assertTrue(remaining.isEmpty()) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + private suspend fun retire( lifecycle: AndroidFileSyncCapabilityLifecycle, retiredPairs: List, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index 59d09774a..42525d92a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -454,6 +454,24 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(fixture.grants.writeGranted) } + @Test + fun `postcommit pair removal failure releases from the authoritative state immediately`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.preparePairCleanup(PAIR_ID) + + assertFailsWith { + fixture.lifecycle.persistPairRemoval(load = { state() }) { + error("save reported failure after commit") + } + } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + private fun fixture( generation: String = NEW_GENERATION, readGranted: Boolean = false, From 0e9a2852c8a8272175b81294b6d9cfb4a534aa7b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:43:28 +0200 Subject: [PATCH 04/16] fix(files): retry folder grant cleanup --- .../AndroidFileSyncCapabilityLifecycle.kt | 18 +++++- .../nextcloudnative/AndroidFileSyncEngine.kt | 2 +- .../AndroidFileSyncExecutionCoordination.kt | 2 +- ...FileSyncAccountRetirementCapabilityTest.kt | 41 +++++++++++++- .../AndroidFileSyncCapabilityLifecycleTest.kt | 55 ++++++++++++++++++- 5 files changed, 110 insertions(+), 8 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt index 0c5effd99..5f4e885f0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -259,6 +259,17 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( finishCleanup(record) } + fun finishPairCleanupOrRetry( + pairId: String, + load: () -> AndroidFileSyncPersistedState, + ) = synchronized(LIFECYCLE_LOCK) { + val pending = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.CleanupPending + } ?: return@synchronized + if (finishCleanup(pending)) return@synchronized + reconcile(load()) + } + fun persistPairRemoval( load: () -> AndroidFileSyncPersistedState, persist: () -> Unit, @@ -299,14 +310,14 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) } } else { - prepareAndFinishCleanup(record) + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } } } AndroidFileSyncCapabilityPhase.Owned -> { if (matchingIds.isNotEmpty() && matchingIds != record.pairIds) { store.replace(record.id, record.phase) { it.copy(pairIds = matchingIds) } } else if (matchingIds.isEmpty() && record.processGeneration != processGeneration) { - prepareAndFinishCleanup(record) + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } } } AndroidFileSyncCapabilityPhase.CleanupPending -> { @@ -315,7 +326,7 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) } } else { - finishCleanup(record) + check(finishCleanup(record)) { CLEANUP_RETRY_MESSAGE } } } } @@ -485,3 +496,4 @@ private fun grantFlags(read: Boolean, write: Boolean): Int = private const val MAX_CAPABILITY_RECORDS = 64 private const val MAX_CAPABILITY_URI_CHARACTERS = 8 * 1024 private const val MAX_CAPABILITY_DISPLAY_NAME_CHARACTERS = 256 +private const val CLEANUP_RETRY_MESSAGE = "Saved folder access cleanup is still pending." diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index aa005ba8b..a80c99bc1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -361,7 +361,7 @@ internal class AndroidFileSyncEngine(context: Context) { } }, cancelSchedule = { scheduler.cancel(pairId) }, - releaseLocalGrant = { capabilities.finishPairCleanup(pairId) }, + releaseLocalGrant = { capabilities.finishPairCleanupOrRetry(pairId, store::load) }, ) if (!removed) { return@withLock FileSyncCenterActionResult.Rejected(if (remoteCleanupRejected) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index d0fc19cd5..f44666144 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -260,7 +260,7 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account }, prepareLocalGrantCleanup = capabilities::preparePairCleanup, persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, - finishLocalGrantCleanup = capabilities::finishPairCleanup, + finishLocalGrantCleanup = { pairId -> capabilities.finishPairCleanupOrRetry(pairId, store::load) }, ) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt index 59fbdf61e..01f7b91c8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt @@ -133,6 +133,41 @@ class AndroidFileSyncAccountRetirementCapabilityTest { assertFalse(fixture.grants.writeGranted) } + @Test + fun `failed retirement grant cleanup remains journaled for an empty-state retry`() = runBlocking { + val fixture = fixture() + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + fixture.lifecycle.reconcile(state(retired)) + fixture.grants.failRelease = true + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retired, + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + prepareLocalGrantCleanup = fixture.lifecycle::preparePairCleanup, + persistRetirement = {}, + finishLocalGrantCleanup = { pairId -> + fixture.lifecycle.finishPairCleanupOrRetry(pairId) { state(emptyList()) } + }, + ) + } + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + + fixture.grants.failRelease = false + val remaining = reconcileAndroidFileSyncAccountRetirement( + state(emptyList()), + REMOVED_ACCOUNT, + fixture.lifecycle, + ) + + assertTrue(remaining.isEmpty()) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + private suspend fun retire( lifecycle: AndroidFileSyncCapabilityLifecycle, retiredPairs: List, @@ -145,7 +180,9 @@ class AndroidFileSyncAccountRetirementCapabilityTest { cancelNotification = {}, prepareLocalGrantCleanup = { pairId -> lifecycle.preparePairCleanup(pairId) }, persistRetirement = persist, - finishLocalGrantCleanup = { pairId -> lifecycle.finishPairCleanup(pairId) }, + finishLocalGrantCleanup = { pairId -> + lifecycle.finishPairCleanupOrRetry(pairId) { state(emptyList()) } + }, ) } @@ -190,11 +227,13 @@ class AndroidFileSyncAccountRetirementCapabilityTest { var readGranted = true var writeGranted = true var releaseCount = 0 + var failRelease = false override fun exactGrant(uri: String) = AndroidFileSyncGrantState(readGranted, writeGranted) override fun takeExactReadWriteGrant(uri: String) = error("Legacy adoption must not take a grant") override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { releaseCount += 1 + if (failRelease) error("release failed") if (read) readGranted = false if (write) writeGranted = false } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index 42525d92a..d8001e236 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -472,6 +472,49 @@ class AndroidFileSyncCapabilityLifecycleTest { assertFalse(fixture.grants.writeGranted) } + @Test + fun `pair cleanup retries an unavailable grant query against authoritative removal`() { + val fixture = preparedCleanup() + fixture.grants.failQueryCount = 1 + + fixture.lifecycle.finishPairCleanupOrRetry(PAIR_ID) { state() } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `pair cleanup retries a failed grant release against authoritative removal`() { + val fixture = preparedCleanup() + fixture.grants.failReleaseCount = 1 + + fixture.lifecycle.finishPairCleanupOrRetry(PAIR_ID) { state() } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + assertEquals(2, fixture.grants.releaseRequests.size) + } + + @Test + fun `pair cleanup retries a failed capability record removal`() { + val fixture = preparedCleanup() + fixture.storage.failWriteNumber = fixture.storage.writes + 1 + + fixture.lifecycle.finishPairCleanupOrRetry(PAIR_ID) { state() } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + private fun preparedCleanup(): Fixture = fixture().also { + it.lifecycle.acquire(ROOT_URI, "Notes") + it.lifecycle.bindReady(ROOT_URI, PAIR_ID) + it.lifecycle.preparePairCleanup(PAIR_ID) + } + private fun fixture( generation: String = NEW_GENERATION, readGranted: Boolean = false, @@ -539,13 +582,18 @@ class AndroidFileSyncCapabilityLifecycleTest { var writeGranted: Boolean, ) : AndroidFileSyncGrantAccess { var failQuery = false + var failQueryCount = 0 var failRelease = false + var failReleaseCount = 0 val events = mutableListOf() val releaseRequests = mutableListOf>() override fun exactGrant(uri: String): AndroidFileSyncGrantState { events += "query" - if (failQuery) error("grant metadata unavailable") + if (failQuery || failQueryCount > 0) { + failQueryCount = (failQueryCount - 1).coerceAtLeast(0) + error("grant metadata unavailable") + } return AndroidFileSyncGrantState(readGranted, writeGranted) } @@ -558,7 +606,10 @@ class AndroidFileSyncCapabilityLifecycleTest { override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { events += "release" releaseRequests += read to write - if (failRelease) error("release failed") + if (failRelease || failReleaseCount > 0) { + failReleaseCount = (failReleaseCount - 1).coerceAtLeast(0) + error("release failed") + } if (read) readGranted = false if (write) writeGranted = false } From 3051fb866fbb6b4269a9790fddc572d291ac137d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:52:56 +0200 Subject: [PATCH 05/16] fix(files): reconcile orphaned upload cleanup --- .../nextcloudnative/AndroidFileSyncEngine.kt | 5 +- .../AndroidFileSyncExecutionCoordination.kt | 2 +- .../nextcloudnative/AndroidFileSyncStore.kt | 14 ++- .../AndroidFileSyncUploadCleanupStore.kt | 7 +- .../AndroidFileSyncStoreTest.kt | 106 ++++++++++++++++++ 5 files changed, 125 insertions(+), 9 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index a80c99bc1..9e883c6a2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -92,7 +92,8 @@ internal class AndroidFileSyncEngine(context: Context) { private val scheduledPairScheduling = DeferredFileSyncPairSchedulingRegistry() private val stagingRoot = File(appContext.cacheDir, "file-sync-staging") private val capabilities = AndroidFileSyncCapabilityLifecycle(appContext) - init { reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, store::load, capabilities) } } + private val loadCapabilityState = store::loadAndReconcileUploadCleanups + init { reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, loadCapabilityState, capabilities) } } suspend fun loadCenter( session: NextcloudSession, userId: String, @@ -353,7 +354,7 @@ internal class AndroidFileSyncEngine(context: Context) { persistRemoval = { capabilities.preparePairCleanup(pairId) val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) - capabilities.persistPairRemoval(store::load) { + capabilities.persistPairRemoval(store::loadAndReconcileUploadCleanups) { store.save(current.copy( coordinator = remaining, localDisplayNames = current.localDisplayNames - pairId, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index f44666144..6940560d7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -243,7 +243,7 @@ internal fun reconcileSafDownloadsBeforePairRemoval( internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String) { AndroidFileSyncEngine.ENGINE_LOCK.withLock { val store = AndroidFileSyncStore(context) - val current = store.load() + val current = store.loadAndReconcileUploadCleanups() val capabilities = AndroidFileSyncCapabilityLifecycle(context) val retiredPairs = reconcileAndroidFileSyncAccountRetirement(current, accountId, capabilities) if (retiredPairs.isEmpty()) return@withLock diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index e4f5acbff..6ec6f8c78 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -58,15 +58,14 @@ internal fun requireAndroidFileSyncAccountRemovalReady( internal class AndroidFileSyncStore internal constructor( private val stateFile: File, private val maximumSnapshotBytes: Int = MAX_SNAPSHOT_BYTES, + private val uploadCleanupStore: AndroidFileSyncUploadCleanupStore = AndroidFileSyncUploadCleanupStore( + File(checkNotNull(stateFile.parentFile), "${stateFile.name}.upload-cleanups"), + ), ) { init { require(maximumSnapshotBytes in 1..MAX_SNAPSHOT_BYTES) } - private val uploadCleanupStore = AndroidFileSyncUploadCleanupStore( - File(checkNotNull(stateFile.parentFile), "${stateFile.name}.upload-cleanups"), - ) - constructor(context: Context) : this(File(context.filesDir, STATE_FILE_NAME)) @Synchronized @@ -123,6 +122,13 @@ internal class AndroidFileSyncStore internal constructor( ) } + @Synchronized + fun loadAndReconcileUploadCleanups(): AndroidFileSyncPersistedState = load().also { state -> + uploadCleanupStore.replace( + state.coordinator.pairs.associate { pair -> pair.id to pair.pendingUploadCleanups }, + ) + } + @Synchronized fun save(state: AndroidFileSyncPersistedState) { val cleanups = state.coordinator.pairs.associate { it.id to it.pendingUploadCleanups } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt index 4ec518aa3..232950257 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt @@ -16,7 +16,10 @@ import java.nio.file.Files import java.nio.file.StandardCopyOption import java.security.MessageDigest -internal class AndroidFileSyncUploadCleanupStore(private val directory: File) { +internal class AndroidFileSyncUploadCleanupStore( + private val directory: File, + private val deleteFile: (File) -> Boolean = File::delete, +) { fun read(): Map> { if (!directory.exists()) return emptyMap() check(directory.isDirectory) { "Folder sync cleanup storage is invalid." } @@ -54,7 +57,7 @@ internal class AndroidFileSyncUploadCleanupStore(private val directory: File) { } checkNotNull(directory.listFiles()) { "Could not list folder sync cleanup storage." } .filter { it.isFile && it.name.endsWith(ROW_SUFFIX) && it.name !in retainedNames } - .forEach { stale -> check(stale.delete()) { "Could not remove obsolete sync cleanup ownership." } } + .forEach { stale -> check(deleteFile(stale)) { "Could not remove obsolete sync cleanup ownership." } } } private fun readRow(file: File): AndroidFileSyncUploadCleanupRow = diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index 00ceed34a..07709edb8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -203,6 +203,102 @@ class AndroidFileSyncStoreTest { } } + @Test + fun `postcommit cleanup failure is reconciled from the authoritative empty snapshot`() { + val directory = Files.createTempDirectory("file-sync-cleanup-retry-").toFile() + try { + val stateFile = File(directory, "state.bin") + var failedDeletes = 0 + val cleanupStore = AndroidFileSyncUploadCleanupStore( + File(directory, "state.bin.upload-cleanups"), + deleteFile = { file -> + if (failedDeletes > 0) { + failedDeletes -= 1 + false + } else { + file.delete() + } + }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = cleanupStore) + val owned = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)))) + failedDeletes = 1 + + assertFailsWith { + store.save(AndroidFileSyncPersistedState()) + } + assertTrue(store.load().coordinator.pairs.isEmpty()) + assertTrue(cleanupStore.read().containsKey(owned.id)) + + assertTrue(store.loadAndReconcileUploadCleanups().coordinator.pairs.isEmpty()) + assertTrue(cleanupStore.read().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `restart cleanup preserves rows owned by a retained account pair`() { + val directory = Files.createTempDirectory("file-sync-cleanup-restart-").toFile() + try { + val stateFile = File(directory, "state.bin") + var failDelete = false + val cleanupDirectory = File(directory, "state.bin.upload-cleanups") + val failingRows = AndroidFileSyncUploadCleanupStore( + cleanupDirectory, + deleteFile = { file -> !failDelete && file.delete() }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = failingRows) + val removed = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + val retained = pair().copy( + id = "pair-2", + accountId = "account-2", + remoteRootPath = "Archive", + pendingUploadCleanups = listOf(cleanup("retained.bin", OTHER_UPLOAD_ID)), + ) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(removed, retained)))) + failDelete = true + + assertFailsWith { + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(retained)))) + } + val restarted = AndroidFileSyncStore(stateFile) + + assertEquals(listOf(retained), restarted.loadAndReconcileUploadCleanups().coordinator.pairs) + assertEquals(setOf(retained.id), AndroidFileSyncUploadCleanupStore(cleanupDirectory).read().keys) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `account cleanup retry fails until obsolete upload rows can be deleted`() { + val directory = Files.createTempDirectory("file-sync-account-cleanup-retry-").toFile() + try { + val stateFile = File(directory, "state.bin") + var deletionAvailable = true + val rows = AndroidFileSyncUploadCleanupStore( + File(directory, "state.bin.upload-cleanups"), + deleteFile = { file -> deletionAvailable && file.delete() }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = rows) + val owned = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)))) + deletionAvailable = false + assertFailsWith { store.save(AndroidFileSyncPersistedState()) } + + assertFailsWith { store.loadAndReconcileUploadCleanups() } + assertTrue(rows.read().containsKey(owned.id)) + deletionAvailable = true + + assertTrue(store.loadAndReconcileUploadCleanups().coordinator.pairs.isEmpty()) + assertTrue(rows.read().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + @Test fun `owned uploads block account removal before pair deletion`() { val accountPair = pair().copy( @@ -237,6 +333,11 @@ class AndroidFileSyncStoreTest { ), ) + private fun cleanup(relativePath: String, uploadId: String = UPLOAD_ID) = FileSyncPendingUploadCleanup( + uploadId = uploadId, + relativePath = relativePath, + ) + private fun withTemporaryStore(block: (AndroidFileSyncStore) -> Unit) { val directory = Files.createTempDirectory("file-sync-store-").toFile() try { @@ -245,4 +346,9 @@ class AndroidFileSyncStoreTest { directory.deleteRecursively() } } + + private companion object { + const val UPLOAD_ID = "01234567-89ab-cdef-0123-456789abcdef" + const val OTHER_UPLOAD_ID = "fedcba98-7654-3210-fedc-ba9876543210" + } } From 5f9a0818f17692131bf179ba054a0165b3a01a74 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:21:47 +0200 Subject: [PATCH 06/16] chore(changelog): link folder grant recovery --- changes/unreleased/android-file-sync-capability-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/android-file-sync-capability-lifecycle.md b/changes/unreleased/android-file-sync-capability-lifecycle.md index 4304e7655..c6ae7d000 100644 --- a/changes/unreleased/android-file-sync-capability-lifecycle.md +++ b/changes/unreleased/android-file-sync-capability-lifecycle.md @@ -1,6 +1,6 @@ category: fix issue: 11 -pull: none +pull: 445 platforms: android user-facing: yes From 2be0ca71e75aa3313baa9ca757c9d09242363658 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 03:52:08 +0200 Subject: [PATCH 07/16] fix(files): recover ambiguous capability binding --- .../nextcloudnative/AndroidFileSyncEngine.kt | 23 +++++++++------ .../AndroidFileSyncExecutionCoordination.kt | 16 ++++++++++ .../AndroidFileSyncCapabilityLifecycleTest.kt | 29 +++++++++++++++++++ .../app/JvmSupportDiagnosticsTest.kt | 13 ++++++--- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 9e883c6a2..377b1798a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -272,15 +272,20 @@ internal class AndroidFileSyncEngine(context: Context) { configuration = configuration, ) val ownsSafGrant = localRoot.localRootId.startsWith("content://") - if (ownsSafGrant) capabilities.bindReady(localRoot.localRootId, pair.id) - try { - store.save(current.copy( - coordinator = addFileSyncPair(current.coordinator, pair), - localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), - )) - } catch (failure: Exception) { - if (ownsSafGrant) recoverFailedFileSyncPairSave(pair.id, store::load, capabilities::abandonUncommittedPair) - throw failure + val updated = current.copy( + coordinator = addFileSyncPair(current.coordinator, pair), + localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), + ) + if (ownsSafGrant) { + bindAndPersistFileSyncPair( + pairId = pair.id, + bindReady = { capabilities.bindReady(localRoot.localRootId, pair.id) }, + persist = { store.save(updated) }, + load = store::load, + abandonUncommittedPair = capabilities::abandonUncommittedPair, + ) + } else { + store.save(updated) } scheduler.schedule(pair.id, accountId, userId, pair.configuration) FileSyncCenterActionResult.Completed("Folder sync pair added. Run it to review the first sync.") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 6940560d7..95e5ef9de 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -126,6 +126,22 @@ internal fun recoverFailedFileSyncPairSave( if (commitIsAbsent) runCatching { abandonUncommittedPair(pairId) } } +internal fun bindAndPersistFileSyncPair( + pairId: String, + bindReady: () -> Unit, + persist: () -> Unit, + load: () -> AndroidFileSyncPersistedState, + abandonUncommittedPair: (String) -> Unit, +) { + try { + bindReady() + persist() + } catch (failure: Exception) { + recoverFailedFileSyncPairSave(pairId, load, abandonUncommittedPair) + throw failure + } +} + /** * Reads a complete atomic snapshot without waiting for active execution. * diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index d8001e236..241fd0da8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -424,6 +424,35 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(fixture.grants.writeGranted) } + @Test + fun `ambiguous bind failure reloads authoritative pairs and abandons uncommitted ownership`() { + val fixture = fixture() + fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.storage.failWriteNumber = fixture.storage.writes + 1 + fixture.storage.persistFailedWrite = true + var reloads = 0 + var pairPersisted = false + + assertFailsWith { + bindAndPersistFileSyncPair( + pairId = PAIR_ID, + bindReady = { fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) }, + persist = { pairPersisted = true }, + load = { + reloads += 1 + state() + }, + abandonUncommittedPair = fixture.lifecycle::abandonUncommittedPair, + ) + } + + assertEquals(1, reloads) + assertFalse(pairPersisted) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + @Test fun `failed pair save releases ownership only when authoritative reload excludes the pair`() { val fixture = fixture() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt index 14a6f0b6b..cae21bdb3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt @@ -317,7 +317,7 @@ class JvmSupportDiagnosticsTest { workers.execute { ready.countDown() start.await() - repeat(20) { index -> + repeat(2) { index -> diagnostics.record( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Warning, @@ -340,10 +340,15 @@ class JvmSupportDiagnosticsTest { assertTrue(ready.await(10L, TimeUnit.SECONDS)) start.countDown() workers.shutdown() - assertTrue(workers.awaitTermination(30L, TimeUnit.SECONDS)) + val completed = try { + workers.awaitTermination(30L, TimeUnit.SECONDS) + } finally { + workers.shutdownNow() + } + assertTrue(completed) - assertEquals(160, diagnostics.summary().eventCount) - assertEquals(160, diagnostics(root).summary().eventCount) + assertEquals(16, diagnostics.summary().eventCount) + assertEquals(16, diagnostics(root).summary().eventCount) assertTrue(File(root, "events-v1.jsonl").length() <= MAX_SUPPORT_DIAGNOSTIC_STORED_BYTES) } From 9fae5630de229589c8962890187d674f7acf363b Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:05:07 +0000 Subject: [PATCH 08/16] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 81309996e..4fdc3ef81 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -13,7 +13,6 @@ "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", @@ -76,10 +75,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -116,6 +113,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRecovery.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt", @@ -463,7 +461,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", + "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": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", @@ -497,11 +495,9 @@ "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": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -515,7 +511,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt": "8b529e61c68ec3ee7937fc3695832284b0b7c88841893fe40d3921234b566e51", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "2870ff58c34e2965f11cf9f891cdbfbfd1f91c49e60ae64cdb93c3d5cb7ce313", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "4ff805297ea403b0e432c3f327ebe47f86041c931c7d185513d71d3775507656", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueue.kt": "f97b055f4278c8dc7e5b3d4ad4284aaafd0194caf01368754daba9f94632ea24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "1282adb909c54d559d812689ca9f937cda3a1256c1400fd0d0f91ba3a1ace1d6", @@ -538,6 +534,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt": "a05565566bc4b78b8bfbf354360b875df88241fa7ea022bfd992d868f6dd5e57", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPresentation.kt": "a64537a74dc68b0a969f86550e23cee7b2998230bc043c47c54794aff829d55b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRecovery.kt": "fd404b75cb8ead34d94d4395489ad8554b45bd671c28bf426bb8f262f1e4153c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt": "9d9209631ae233337721cf2fcac2a8150d2c3bba74cde75a05fe8a144a2a321d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt": "33242928be5d216ad664c742212994b1074d7c5d8947f18906c1feb78d922d1b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt": "de8740ee24e98477b7ac3fac51001f7fb8d86a5a2b589905724a621e32ddc7b9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", @@ -552,7 +549,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", @@ -640,9 +637,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5370183fb78190a6893e4a36638b0570de4d2d6b33acab18aef52f2b83d83589", From c8ff76dd4153713bea3babaf5516eaedc0904193 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:38:19 +0200 Subject: [PATCH 09/16] test(android): update account retirement cleanup contract --- .../AndroidAccountRecoveryPriorityTest.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt index 49f6a8095..e35400ba6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt @@ -98,7 +98,7 @@ class AndroidAccountRecoveryPriorityTest { } @Test - fun accountRetirementRetainsPairMappingUntilEverySafGrantReleaseIsAttempted() = runBlocking { + fun accountRetirementPersistsAfterEveryGrantCleanupIsPrepared() = runBlocking { val retiredPairs = listOf( fileSyncPair("retired-a", "content://documents/first"), fileSyncPair("retired-b", "content://documents/second"), @@ -108,19 +108,22 @@ class AndroidAccountRecoveryPriorityTest { assertFailsWith { retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = emptyList(), reconcileLocalDownloads = { true }, cancelSchedule = {}, cancelNotification = {}, + prepareLocalGrantCleanup = { pairId -> events += "prepare-$pairId" }, persistRetirement = { events += "persist-retirement" }, - releaseLocalGrant = { localRootId -> - events += "release-$localRootId" - if (localRootId.endsWith("first")) error("synthetic grant release interruption") + finishLocalGrantCleanup = { pairId -> + events += "finish-$pairId" + if (pairId == "retired-a") error("synthetic grant release interruption") }, ) } - assertEquals(listOf("release-content://documents/first"), events) + assertEquals( + listOf("prepare-retired-a", "prepare-retired-b", "persist-retirement", "finish-retired-a"), + events, + ) } private fun fileSyncPair(id: String, localRootId: String) = FileSyncPair( From 3c85cae2191950321313ef9badb67b65691dd740 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:25:07 +0200 Subject: [PATCH 10/16] fix(android): preserve folder sync setup recovery --- .../AndroidFileSyncCapabilityLifecycle.kt | 1 + .../AndroidFileSyncExecutionCoordination.kt | 6 +- .../AndroidFileSyncRootPicker.kt | 5 +- .../AndroidNextcloudServices.kt | 9 +- ...FileSyncAccountRetirementCapabilityTest.kt | 23 +++ .../AndroidFileSyncCapabilityLifecycleTest.kt | 15 ++ .../AndroidFileSyncEngineInvariantTest.kt | 13 +- .../app/FileOfflineCenterScreen.kt | 52 ++++--- .../app/FileSyncRootLifecycle.kt | 135 +++++++++++++++++- .../nextcloudnative/app/NextcloudPlatform.kt | 4 +- .../app/FileSyncRootLifecycleTest.kt | 76 ++++++++++ 11 files changed, 292 insertions(+), 47 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt index 5f4e885f0..d5e9dea7e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -221,6 +221,7 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( it.phase in setOf( AndroidFileSyncCapabilityPhase.Acquiring, AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.CleanupPending, ) } ?: return@synchronized false prepareAndFinishCleanup(record) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 95e5ef9de..363417a0e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -305,6 +305,11 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( } currentCoroutineContext().ensureActive() } + withContext(NonCancellable) { + retiredPairs.forEach { pair -> prepareLocalGrantCleanup(pair.id) } + } + currentCoroutineContext().ensureActive() + retiredPairs.forEach { pair -> cancelSchedule(pair) cancelNotification(pair) @@ -312,7 +317,6 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( currentCoroutineContext().ensureActive() withContext(NonCancellable) { - retiredPairs.forEach { pair -> prepareLocalGrantCleanup(pair.id) } persistRetirement() retiredPairs.forEach { pair -> finishLocalGrantCleanup(pair.id) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index 5fdaf1788..ff57d92a8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -56,9 +56,8 @@ internal class AndroidFileSyncRootPicker( .onFailure { continuation.cancel(it) } } - fun abandon(localRootId: String) { - runCatching { capabilities.abandonSelection(localRootId) } - } + fun abandon(localRootId: String): Boolean = + runCatching { capabilities.abandonSelection(localRootId) }.getOrDefault(false) private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { val documentId = DocumentsContract.getTreeDocumentId(treeUri) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index fe38b28bf..e881448c9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1675,11 +1675,11 @@ internal class AndroidNextcloudServices( ) } override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = - checkNotNull(fileSyncRootPicker) { - "The native folder chooser is not available from this Android component." - }.choose(initialRootHint) + checkNotNull(fileSyncRootPicker) { "The native folder chooser is not available from this Android component." } + .choose(initialRootHint) override fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = - fileSyncRootPicker?.abandon(localRoot.localRootId) ?: Unit + fileSyncRootPicker?.abandon(localRoot.localRootId) ?: true + override fun retainFileSyncRootOnDispose(): Boolean = activity?.isChangingConfigurations == true override suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, @@ -1688,7 +1688,6 @@ internal class AndroidNextcloudServices( override fun openIncomingShareRecovery(requestId: String) = openAndroidIncomingShareRecovery(appContext, requestId) - override suspend fun discoverMediaSyncFolders(): MediaSyncFolderDiscovery = withContext(Dispatchers.IO) { mediaSyncFolderDetector.discover() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt index 01f7b91c8..d4d82bd7a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt @@ -75,6 +75,29 @@ class AndroidFileSyncAccountRetirementCapabilityTest { assertFalse(fixture.grants.writeGranted) } + @Test + fun `failed grant preparation leaves account sync schedules active`() = runBlocking { + val retired = listOf(pair(FIRST_PAIR_ID, REMOVED_ACCOUNT)) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retired, + reconcileLocalDownloads = { true }, + cancelSchedule = { events += "cancel-schedule" }, + cancelNotification = { events += "cancel-notification" }, + prepareLocalGrantCleanup = { + events += "prepare-grant" + error("synthetic grant preparation failure") + }, + persistRetirement = { events += "persist-retirement" }, + finishLocalGrantCleanup = { events += "finish-grant" }, + ) + } + + assertEquals(listOf("prepare-grant"), events) + } + @Test fun `failed precommit save restores ownership from the authoritative pair on restart`() = runBlocking { val fixture = fixture(OLD_GENERATION) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index 241fd0da8..a7f9b2ac3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -201,6 +201,21 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(fixture.store.list().isEmpty()) } + @Test + fun `failed setup abandonment remains retryable without restart`() { + val fixture = fixture() + val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.grants.failReleaseCount = 1 + + assertFalse(fixture.lifecycle.abandonSelection(root.localRootId)) + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + + assertTrue(fixture.lifecycle.abandonSelection(root.localRootId)) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + @Test fun `prior process ready record is released when no pair owns it`() { val fixture = fixture(generation = NEW_GENERATION) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 5fea9e38e..ab7d469eb 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -568,15 +568,15 @@ class AndroidFileSyncEngineInvariantTest { "reconcile-retired-a", "reconcile-retired-b", "reconcile-retired-c", + "prepare-retired-a", + "prepare-retired-b", + "prepare-retired-c", "cancel-retired-a", "cancel-notification-retired-a", "cancel-retired-b", "cancel-notification-retired-b", "cancel-retired-c", "cancel-notification-retired-c", - "prepare-retired-a", - "prepare-retired-b", - "prepare-retired-c", "persist-retirement", "finish-retired-a", "finish-retired-b", @@ -632,7 +632,10 @@ class AndroidFileSyncEngineInvariantTest { ) } - assertEquals(listOf("cancel-pair-a", "cancel-notification-pair-a", "cancel-pair-b"), events) + assertEquals( + listOf("prepare-pair-a", "prepare-pair-b", "cancel-pair-a", "cancel-notification-pair-a", "cancel-pair-b"), + events, + ) } @Test @@ -655,7 +658,7 @@ class AndroidFileSyncEngineInvariantTest { ) } - assertEquals(listOf("cancel-schedule", "cancel-notification"), events) + assertEquals(listOf("prepare-grant", "cancel-schedule", "cancel-notification"), events) } @Test diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index c2f272671..cd9f95a5c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt @@ -99,25 +99,20 @@ internal fun FileOfflineCenterScreen( var mediaFolderDiscovery by remember(session, userId) { mutableStateOf(null) } var mediaDiscoveryLoading by remember(session, userId) { mutableStateOf(false) } var syncBusyPairIds by remember(session, userId) { mutableStateOf>(emptySet()) } - val pendingLocalRootState = remember(session, userId) { - mutableStateOf(null) - } - var pendingLocalRoot by pendingLocalRootState - var pendingMediaSuggestionJson by remember(session, userId) { - mutableStateOf(null) - } - var pendingRemotePath by remember(session, userId) { - mutableStateOf(null) - } - var pendingSyncConfigurationJson by remember(session, userId) { - mutableStateOf(null) - } - var remoteFolderPickerVisible by remember(session, userId) { - mutableStateOf(false) - } - var syncSelectionPickerVisible by remember(session, userId) { - mutableStateOf(false) + val setupDraft = rememberSaveable( + session.serverUrl, + session.loginName, + userId, + saver = FileSyncSetupDraftSaver, + ) { + FileSyncSetupDraftState() } + var pendingLocalRoot by setupDraft.localRoot + var pendingMediaSuggestionJson by setupDraft.mediaSuggestionJson + var pendingRemotePath by setupDraft.remotePath + var pendingSyncConfigurationJson by setupDraft.configurationJson + var remoteFolderPickerVisible by setupDraft.remoteFolderPickerVisible + var syncSelectionPickerVisible by setupDraft.selectionPickerVisible val pendingMediaSuggestion = pendingMediaSuggestionJson?.let { encoded -> runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() } @@ -149,16 +144,15 @@ internal fun FileOfflineCenterScreen( var virtualFolderPickerError by remember(session, userId) { mutableStateOf(null) } var releaseVirtualFolderPath by remember(session, userId) { mutableStateOf(null) } val scope = rememberCoroutineScope() - fun abandonPendingFolderSync() { - pendingLocalRoot?.let(services::abandonFileSyncLocalRoot) - pendingLocalRoot = null - pendingMediaSuggestionJson = null - pendingRemotePath = null - pendingSyncConfigurationJson = null + fun abandonPendingFolderSync(): Boolean { + val abandoned = setupDraft.abandon(services::abandonFileSyncLocalRoot) + if (!abandoned) { + actionMessage = "Could not release the selected folder. Choose Add folder to retry cleanup." + } pendingMediaPreview = null - syncSelectionPickerVisible = false + return abandoned } - AbandonFileSyncRootOnDispose(services, pendingLocalRootState) + AbandonFileSyncRootOnDispose(services, setupDraft.localRoot) fun runItemAction(item: FileOfflineCenterItem, remove: Boolean) { if (actionKey != null) return actionKey = item.key @@ -203,6 +197,7 @@ internal fun FileOfflineCenterScreen( fun beginAddFolderSync() { if (ADD_PAIR_BUSY_ID in syncBusyPairIds) return + if (pendingLocalRoot != null && !abandonPendingFolderSync()) return syncBusyPairIds += ADD_PAIR_BUSY_ID scope.launch { try { @@ -1097,14 +1092,15 @@ internal fun FileOfflineCenterScreen( }.onSuccess { result -> actionMessage = result.fileSyncCenterMessage() if (result is FileSyncCenterActionResult.Completed) { - abandonPendingFolderSync() + setupDraft.clear() + pendingMediaPreview = null refreshAttempt += 1 } else { abandonPendingFolderSync() } }.onFailure { failure -> - abandonPendingFolderSync() actionMessage = failure.message ?: "Could not add this folder sync pair." + abandonPendingFolderSync() } syncBusyPairIds -= ADD_PAIR_BUSY_ID } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt index c123e2963..93f4b3d25 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt @@ -2,7 +2,116 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import kotlinx.coroutines.CancellationException + +internal class FileSyncSetupDraftState private constructor( + localRoot: FileSyncLocalRoot?, + mediaSuggestionJson: String?, + remotePath: String?, + configurationJson: String?, + remoteFolderPickerVisible: Boolean, + selectionPickerVisible: Boolean, +) { + constructor() : this(null, null, null, null, false, false) + + val localRoot: MutableState = mutableStateOf(localRoot) + val mediaSuggestionJson: MutableState = mutableStateOf(mediaSuggestionJson) + val remotePath: MutableState = mutableStateOf(remotePath) + val configurationJson: MutableState = mutableStateOf(configurationJson) + val remoteFolderPickerVisible: MutableState = mutableStateOf(remoteFolderPickerVisible) + val selectionPickerVisible: MutableState = mutableStateOf(selectionPickerVisible) + + fun clear() { + localRoot.value = null + mediaSuggestionJson.value = null + remotePath.value = null + configurationJson.value = null + remoteFolderPickerVisible.value = false + selectionPickerVisible.value = false + } + + fun abandon(abandonRoot: (FileSyncLocalRoot) -> Boolean): Boolean { + val abandoned = localRoot.value?.let { root -> tryAbandonFileSyncRoot(root, abandonRoot) } ?: true + if (abandoned) { + clear() + } else { + remotePath.value = null + configurationJson.value = null + remoteFolderPickerVisible.value = false + selectionPickerVisible.value = false + } + return abandoned + } + + companion object { + fun restore(saved: List): FileSyncSetupDraftState? { + if (saved.size != SAVED_SETUP_FIELD_COUNT || saved[0] != SAVED_SETUP_VERSION || + saved.sumOf(String::length) > MAX_SAVED_SETUP_CHARACTERS + ) { + return null + } + val root = when (saved[1]) { + "0" -> null + "1" -> runCatching { FileSyncLocalRoot(saved[2], saved[3]) }.getOrNull() ?: return null + else -> return null + } + val remotePath = when (saved[5]) { + "0" -> null + "1" -> saved[6] + else -> return null + } + val remotePickerVisible = saved[8].toBooleanStrictOrNull() ?: return null + val selectionPickerVisible = saved[9].toBooleanStrictOrNull() ?: return null + return FileSyncSetupDraftState( + localRoot = root, + mediaSuggestionJson = saved[4].ifEmpty { null }, + remotePath = remotePath, + configurationJson = saved[7].ifEmpty { null }, + remoteFolderPickerVisible = remotePickerVisible, + selectionPickerVisible = selectionPickerVisible, + ) + } + } +} + +internal fun FileSyncSetupDraftState.savedState(): List? { + val root = localRoot.value + val remote = remotePath.value + val saved = listOf( + SAVED_SETUP_VERSION, + if (root == null) "0" else "1", + root?.localRootId.orEmpty(), + root?.displayName.orEmpty(), + mediaSuggestionJson.value.orEmpty(), + if (remote == null) "0" else "1", + remote.orEmpty(), + configurationJson.value.orEmpty(), + remoteFolderPickerVisible.value.toString(), + selectionPickerVisible.value.toString(), + ) + if (saved.sumOf(String::length) <= MAX_SAVED_SETUP_CHARACTERS) return saved + return listOf( + SAVED_SETUP_VERSION, + if (root == null) "0" else "1", + root?.localRootId.orEmpty(), + root?.displayName.orEmpty(), + "", + "0", + "", + "", + "false", + "false", + ) +} + +internal val FileSyncSetupDraftSaver = Saver>( + save = { draft -> draft.savedState() }, + restore = { saved -> FileSyncSetupDraftState.restore(saved) }, +) @Composable internal fun AbandonFileSyncRootOnDispose( @@ -10,11 +119,31 @@ internal fun AbandonFileSyncRootOnDispose( localRoot: State, ) { DisposableEffect(services, localRoot) { - onDispose(fileSyncRootDisposal({ localRoot.value }, services::abandonFileSyncLocalRoot)) + onDispose(fileSyncRootDisposal( + currentRoot = { localRoot.value }, + abandon = services::abandonFileSyncLocalRoot, + retainRoot = services::retainFileSyncRootOnDispose, + )) } } internal fun fileSyncRootDisposal( currentRoot: () -> FileSyncLocalRoot?, - abandon: (FileSyncLocalRoot) -> Unit, -): () -> Unit = { currentRoot()?.let(abandon) } + abandon: (FileSyncLocalRoot) -> Boolean, + retainRoot: () -> Boolean = { false }, +): () -> Unit = { if (!retainRoot()) currentRoot()?.let(abandon) } + +private fun tryAbandonFileSyncRoot( + root: FileSyncLocalRoot, + abandon: (FileSyncLocalRoot) -> Boolean, +): Boolean = try { + abandon(root) +} catch (failure: CancellationException) { + throw failure +} catch (_: Exception) { + false +} + +private const val SAVED_SETUP_FIELD_COUNT = 10 +private const val MAX_SAVED_SETUP_CHARACTERS = 32 * 1024 +private const val SAVED_SETUP_VERSION = "file-sync-setup-v1" 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 71abbf984..dfe07d1c1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -935,14 +935,14 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa /** Opens the native folder chooser and persists a least-privilege folder grant. */ suspend fun chooseFileSyncLocalRoot(initialRootHint: String? = null): FileSyncLocalRoot? = null - fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = Unit + fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot): Boolean = true + fun retainFileSyncRootOnDispose(): Boolean = false /** Lists durable share-sheet uploads that still need progress or user review. */ suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, cursor: String?, ): IncomingShareRecoveryPage = IncomingShareRecoveryPage() - /** Opens the platform-owned recovery surface for one durable share-sheet upload. */ fun openIncomingShareRecovery(requestId: String) = Unit diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt index cd27df179..927a48076 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt @@ -2,6 +2,10 @@ 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 FileSyncRootLifecycleTest { @Test @@ -16,4 +20,76 @@ class FileSyncRootLifecycleTest { assertEquals(listOf(deliveredRoot), abandoned) } + + @Test + fun `activity recreation retains the delivered root for restored setup`() { + val deliveredRoot = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + val abandoned = mutableListOf() + + fileSyncRootDisposal( + currentRoot = { deliveredRoot }, + retainRoot = { true }, + abandon = abandoned::add, + ).invoke() + + assertTrue(abandoned.isEmpty()) + } + + @Test + fun `setup draft restores the selected root destination and configuration`() { + val draft = FileSyncSetupDraftState().apply { + localRoot.value = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + mediaSuggestionJson.value = "{\"kind\":\"notes\"}" + remotePath.value = "Shared/Notes" + configurationJson.value = "{\"direction\":\"Bidirectional\"}" + remoteFolderPickerVisible.value = true + selectionPickerVisible.value = true + } + + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + + assertEquals(draft.localRoot.value, restored.localRoot.value) + assertEquals(draft.mediaSuggestionJson.value, restored.mediaSuggestionJson.value) + assertEquals(draft.remotePath.value, restored.remotePath.value) + assertEquals(draft.configurationJson.value, restored.configurationJson.value) + assertTrue(restored.remoteFolderPickerVisible.value) + assertTrue(restored.selectionPickerVisible.value) + } + + @Test + fun `oversized optional setup retains the selected root across recreation`() { + val root = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + configurationJson.value = "x".repeat(32 * 1024) + } + + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + + assertEquals(root, restored.localRoot.value) + assertNull(restored.configurationJson.value) + } + + @Test + fun `failed abandonment keeps the root available for retry`() { + val root = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + remotePath.value = "Shared/Notes" + configurationJson.value = "configuration" + remoteFolderPickerVisible.value = true + } + + assertFalse(draft.abandon { false }) + assertEquals(root, draft.localRoot.value) + assertNull(draft.remotePath.value) + assertNull(draft.configurationJson.value) + assertFalse(draft.remoteFolderPickerVisible.value) + + assertFalse(draft.abandon { error("synthetic grant release failure") }) + assertEquals(root, draft.localRoot.value) + + assertTrue(draft.abandon { true }) + assertNull(draft.localRoot.value) + } } From f0f7ddfb602054d9677ed7023e7a9da34da97ed2 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:02:41 +0000 Subject: [PATCH 11/16] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 4fdc3ef81..c215d2a23 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -511,7 +511,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt": "8b529e61c68ec3ee7937fc3695832284b0b7c88841893fe40d3921234b566e51", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "4ff805297ea403b0e432c3f327ebe47f86041c931c7d185513d71d3775507656", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "4a2e6340478da7d40dfe327af2ceebd750cdb47994fc17e9ee830ec37bd14d46", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueue.kt": "f97b055f4278c8dc7e5b3d4ad4284aaafd0194caf01368754daba9f94632ea24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "1282adb909c54d559d812689ca9f937cda3a1256c1400fd0d0f91ba3a1ace1d6", @@ -534,7 +534,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt": "a05565566bc4b78b8bfbf354360b875df88241fa7ea022bfd992d868f6dd5e57", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPresentation.kt": "a64537a74dc68b0a969f86550e23cee7b2998230bc043c47c54794aff829d55b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRecovery.kt": "fd404b75cb8ead34d94d4395489ad8554b45bd671c28bf426bb8f262f1e4153c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt": "9d9209631ae233337721cf2fcac2a8150d2c3bba74cde75a05fe8a144a2a321d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt": "2a057037578511b359633b19d4ff2f8ec7e3ef7f013aa0e88eaeeb6b506c95f6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt": "33242928be5d216ad664c742212994b1074d7c5d8947f18906c1feb78d922d1b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt": "de8740ee24e98477b7ac3fac51001f7fb8d86a5a2b589905724a621e32ddc7b9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", @@ -642,7 +642,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5370183fb78190a6893e4a36638b0570de4d2d6b33acab18aef52f2b83d83589", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b712a992ee8aff25a7beaf954cae58bd0fda2946e35a650fa037b9dbdd8ee957", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", From 2a4447119bdb481204b17e95836f72453a018876 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:10:06 +0200 Subject: [PATCH 12/16] refactor(android): preserve sync engine boundary --- .../dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 377b1798a..820be9399 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -271,12 +271,11 @@ internal class AndroidFileSyncEngine(context: Context) { remoteRootPath = normalizedRemote, configuration = configuration, ) - val ownsSafGrant = localRoot.localRootId.startsWith("content://") val updated = current.copy( coordinator = addFileSyncPair(current.coordinator, pair), localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), ) - if (ownsSafGrant) { + if (localRoot.localRootId.startsWith("content://")) { bindAndPersistFileSyncPair( pairId = pair.id, bindReady = { capabilities.bindReady(localRoot.localRootId, pair.id) }, @@ -360,10 +359,7 @@ internal class AndroidFileSyncEngine(context: Context) { capabilities.preparePairCleanup(pairId) val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) capabilities.persistPairRemoval(store::loadAndReconcileUploadCleanups) { - store.save(current.copy( - coordinator = remaining, - localDisplayNames = current.localDisplayNames - pairId, - )) + store.save(current.copy(coordinator = remaining, localDisplayNames = current.localDisplayNames - pairId)) } }, cancelSchedule = { scheduler.cancel(pairId) }, From 8a221b15b41702710018fe2135571622bec6ba87 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:22:56 +0000 Subject: [PATCH 13/16] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index c215d2a23..37291b7c4 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -13,6 +13,7 @@ "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", @@ -75,8 +76,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -461,7 +464,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", @@ -495,9 +498,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -549,7 +554,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", @@ -637,9 +642,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b712a992ee8aff25a7beaf954cae58bd0fda2946e35a650fa037b9dbdd8ee957", From 3ddf12dcebce6d0504eef5ba0ecabab11518f1c1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:31:56 +0200 Subject: [PATCH 14/16] fix(android): preserve restored folder capabilities --- .../AndroidFileSyncCapabilityLifecycle.kt | 117 ++++++++- .../nextcloudnative/AndroidFileSyncEngine.kt | 3 +- .../AndroidFileSyncExecutionCoordination.kt | 16 ++ .../AndroidFileSyncRootPicker.kt | 37 ++- .../AndroidNextcloudServices.kt | 13 +- .../AndroidFileSyncCapabilityLifecycleTest.kt | 223 +++++++++++++++--- .../app/FileOfflineCenterScreen.kt | 6 +- .../nextcloudnative/app/NextcloudPlatform.kt | 4 +- .../app/DesktopNextcloudServices.kt | 2 +- 9 files changed, 371 insertions(+), 50 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt index d5e9dea7e..b3a461773 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -25,6 +25,7 @@ internal data class AndroidFileSyncCapabilityRecord( val processGeneration: String, val preExistingReadGrant: Boolean, val preExistingWriteGrant: Boolean, + val accountId: AndroidFileSyncCapabilityAccountId? = null, val pairIds: Set = emptySet(), ) { init { @@ -64,6 +65,15 @@ internal interface AndroidFileSyncGrantAccess { internal data class AndroidFileSyncGrantState(val read: Boolean, val write: Boolean) +@JvmInline +internal value class AndroidFileSyncCapabilityAccountId(val value: String) { + init { + require(value.isNotBlank() && value.length <= MAX_CAPABILITY_ACCOUNT_ID_CHARACTERS) { + "The folder capability account is invalid." + } + } +} + internal fun hasDuplicateAndroidFileSyncRoot( pairs: List, accountId: String, @@ -177,7 +187,11 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( PROCESS_GENERATION, ) - fun acquire(exactUri: String, displayName: String): FileSyncLocalRoot = synchronized(LIFECYCLE_LOCK) { + fun acquire( + accountId: AndroidFileSyncCapabilityAccountId, + exactUri: String, + displayName: String, + ): FileSyncLocalRoot = synchronized(LIFECYCLE_LOCK) { val preExisting = grants.exactGrant(exactUri) val record = AndroidFileSyncCapabilityRecord( id = UUID.randomUUID().toString(), @@ -187,6 +201,7 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( processGeneration = processGeneration, preExistingReadGrant = preExisting.read, preExistingWriteGrant = preExisting.write, + accountId = accountId, ) try { store.add(record) @@ -205,9 +220,15 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( } } - fun bindReady(localRootId: String, pairId: String) = synchronized(LIFECYCLE_LOCK) { + fun bindReady( + accountId: AndroidFileSyncCapabilityAccountId, + localRootId: String, + pairId: String, + ) = synchronized(LIFECYCLE_LOCK) { val record = store.list().singleOrNull { - it.uri == localRootId && it.phase == AndroidFileSyncCapabilityPhase.Ready + it.uri == localRootId && + it.accountId == accountId && + it.phase == AndroidFileSyncCapabilityPhase.Ready } ?: error("The selected local folder is no longer available.") store.replace(record.id, AndroidFileSyncCapabilityPhase.Ready) { it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = setOf(pairId)) @@ -303,17 +324,32 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( val matchingPairs = safPairs.filter { it.localRootId == record.uri } val matchingIds = matchingPairs.mapTo(linkedSetOf(), FileSyncPair::id) when (record.phase) { - AndroidFileSyncCapabilityPhase.Ready, - AndroidFileSyncCapabilityPhase.Acquiring, - -> if (record.processGeneration != processGeneration) { + AndroidFileSyncCapabilityPhase.Acquiring -> if (record.processGeneration != processGeneration) { if (matchingIds.isNotEmpty()) { store.replace(record.id, record.phase) { - it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) + it.copy( + phase = AndroidFileSyncCapabilityPhase.Owned, + accountId = matchingPairs.singleAccountOwner(), + pairIds = matchingIds, + ) } } else { check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } } } + AndroidFileSyncCapabilityPhase.Ready -> if (record.processGeneration != processGeneration) { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy( + phase = AndroidFileSyncCapabilityPhase.Owned, + accountId = matchingPairs.singleAccountOwner(), + pairIds = matchingIds, + ) + } + } else if (record.accountId == null) { + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + } AndroidFileSyncCapabilityPhase.Owned -> { if (matchingIds.isNotEmpty() && matchingIds != record.pairIds) { store.replace(record.id, record.phase) { it.copy(pairIds = matchingIds) } @@ -334,11 +370,62 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( } } + fun reconcileRestoredSetup( + accountId: AndroidFileSyncCapabilityAccountId, + restoredLocalRootId: String?, + state: AndroidFileSyncPersistedState, + ): Boolean = reconcileSetup(accountId, restoredLocalRootId, state, includeCurrentGeneration = false) + + fun retireAccountSetup( + accountId: AndroidFileSyncCapabilityAccountId, + state: AndroidFileSyncPersistedState, + ) = reconcileSetup(accountId, restoredLocalRootId = null, state, includeCurrentGeneration = true) + + private fun reconcileSetup( + accountId: AndroidFileSyncCapabilityAccountId, + restoredLocalRootId: String?, + state: AndroidFileSyncPersistedState, + includeCurrentGeneration: Boolean, + ): Boolean = synchronized(LIFECYCLE_LOCK) { + reconcile(state) + val restoredContentRoot = restoredLocalRootId?.takeIf { it.startsWith("content://") } + val records = store.list() + val restored = restoredContentRoot?.let { uri -> + records.singleOrNull { record -> + record.uri == uri && + record.accountId == accountId && + record.phase == AndroidFileSyncCapabilityPhase.Ready + } + } + if (restored != null && restored.processGeneration != processGeneration) { + store.replace(restored.id, AndroidFileSyncCapabilityPhase.Ready) { + it.copy(processGeneration = processGeneration) + } + } + records.asSequence() + .filter { record -> + record.accountId == accountId && + record.phase == AndroidFileSyncCapabilityPhase.Ready && + (includeCurrentGeneration || record.processGeneration != processGeneration) && + record.id != restored?.id + } + .forEach { record -> + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + restoredContentRoot == null || restored != null + } + private fun hasConflictingOwnership( records: List, pairs: List, ): Boolean = records.any { record -> - record.pairIds.any { pairId -> pairs.any { it.id == pairId && it.localRootId != record.uri } } + record.pairIds.any { pairId -> + pairs.any { pair -> + pair.id == pairId && + (pair.localRootId != record.uri || + record.accountId?.value?.let { owner -> owner != pair.accountId } == true) + } + } } private fun adoptLegacyCapability( @@ -358,6 +445,7 @@ internal class AndroidFileSyncCapabilityLifecycle internal constructor( processGeneration = processGeneration, preExistingReadGrant = false, preExistingWriteGrant = false, + accountId = pairs.singleAccountOwner(), pairIds = pairIds, )) } @@ -470,6 +558,7 @@ private fun AndroidFileSyncCapabilityRecord.toJson(): JSONObject = JSONObject() .put("processGeneration", processGeneration) .put("preExistingReadGrant", preExistingReadGrant) .put("preExistingWriteGrant", preExistingWriteGrant) + .put("accountId", accountId?.value) .put("pairIds", JSONArray().also { array -> pairIds.sorted().forEach(array::put) }) private fun JSONObject.toCapabilityRecord(): AndroidFileSyncCapabilityRecord = AndroidFileSyncCapabilityRecord( @@ -480,6 +569,7 @@ private fun JSONObject.toCapabilityRecord(): AndroidFileSyncCapabilityRecord = A processGeneration = getString("processGeneration"), preExistingReadGrant = getBoolean("preExistingReadGrant"), preExistingWriteGrant = getBoolean("preExistingWriteGrant"), + accountId = optionalCapabilityAccountId(), pairIds = when { has("pairIds") -> getJSONArray("pairIds").let { array -> buildSet { repeat(array.length()) { add(array.getString(it)) } } @@ -498,3 +588,14 @@ private const val MAX_CAPABILITY_RECORDS = 64 private const val MAX_CAPABILITY_URI_CHARACTERS = 8 * 1024 private const val MAX_CAPABILITY_DISPLAY_NAME_CHARACTERS = 256 private const val CLEANUP_RETRY_MESSAGE = "Saved folder access cleanup is still pending." +private const val MAX_CAPABILITY_ACCOUNT_ID_CHARACTERS = 256 + +private fun List.singleAccountOwner(): AndroidFileSyncCapabilityAccountId? = + map(FileSyncPair::accountId).distinct().singleOrNull()?.let(::AndroidFileSyncCapabilityAccountId) + +private fun JSONObject.optionalCapabilityAccountId(): AndroidFileSyncCapabilityAccountId? = + when (val stored = opt("accountId")) { + null, JSONObject.NULL -> null + is String -> AndroidFileSyncCapabilityAccountId(stored) + else -> error("Saved folder capability account is invalid.") + } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 820be9399..fe07ea97b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -94,6 +94,7 @@ internal class AndroidFileSyncEngine(context: Context) { private val capabilities = AndroidFileSyncCapabilityLifecycle(appContext) private val loadCapabilityState = store::loadAndReconcileUploadCleanups init { reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, loadCapabilityState, capabilities) } } + suspend fun loadCenter( session: NextcloudSession, userId: String, @@ -278,7 +279,7 @@ internal class AndroidFileSyncEngine(context: Context) { if (localRoot.localRootId.startsWith("content://")) { bindAndPersistFileSyncPair( pairId = pair.id, - bindReady = { capabilities.bindReady(localRoot.localRootId, pair.id) }, + bindReady = { capabilities.bindReady(AndroidFileSyncCapabilityAccountId(accountId), localRoot.localRootId, pair.id) }, persist = { store.save(updated) }, load = store::load, abandonUncommittedPair = capabilities::abandonUncommittedPair, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 363417a0e..0bd281b72 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -113,6 +113,18 @@ internal suspend fun reconcileFileSyncCapabilities( } } +internal suspend fun reconcileRestoredFileSyncSetup( + context: Context, + session: dev.obiente.nextcloudnative.app.NextcloudSession, + restoredLocalRoot: dev.obiente.nextcloudnative.app.FileSyncLocalRoot?, +): Boolean = AndroidFileSyncEngine.ENGINE_LOCK.withLock { + AndroidFileSyncCapabilityLifecycle(context).reconcileRestoredSetup( + accountId = AndroidFileSyncCapabilityAccountId(NextcloudDocumentIds.accountKey(session)), + restoredLocalRootId = restoredLocalRoot?.localRootId, + state = AndroidFileSyncStore(context).load(), + ) +} + internal fun recoverFailedFileSyncPairSave( pairId: String, load: () -> AndroidFileSyncPersistedState, @@ -261,6 +273,10 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account val store = AndroidFileSyncStore(context) val current = store.loadAndReconcileUploadCleanups() val capabilities = AndroidFileSyncCapabilityLifecycle(context) + capabilities.retireAccountSetup( + AndroidFileSyncCapabilityAccountId(accountId), + state = current, + ) val retiredPairs = reconcileAndroidFileSyncAccountRetirement(current, accountId, capabilities) if (retiredPairs.isEmpty()) return@withLock val scheduler = AndroidFileSyncScheduler(context) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index ff57d92a8..84d7d37f9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -21,34 +21,43 @@ internal class AndroidFileSyncRootPicker( private val capabilities: AndroidFileSyncCapabilityLifecycle = AndroidFileSyncCapabilityLifecycle(context), ) { private var launcher: ActivityResultLauncher? = null - private var pending: CancellableContinuation? = null + private var pending: PendingFileSyncRootSelection? = null fun attach(launcher: ActivityResultLauncher) { check(this.launcher == null) { "The sync-root picker is already attached." } this.launcher = launcher } - suspend fun choose(initialRootHint: String? = null): FileSyncLocalRoot? = + suspend fun choose( + accountId: AndroidFileSyncCapabilityAccountId, + initialRootHint: String? = null, + ): FileSyncLocalRoot? = suspendCancellableCoroutine { continuation -> check(pending == null) { "A folder chooser is already open." } val activeLauncher = checkNotNull(launcher) { "The folder chooser is not attached." } - pending = continuation + val selection = PendingFileSyncRootSelection(accountId, continuation) + pending = selection continuation.invokeOnCancellation { - if (pending === continuation) pending = null + if (pending === selection) pending = null } activeLauncher.launch(initialRootHint?.let(Uri::parse)) } fun complete(uri: Uri?) { - val continuation = pending ?: return + val selection = pending ?: return pending = null + val continuation = selection.continuation if (!continuation.isActive) return if (uri == null) { continuation.resume(null) return } val result = runCatching { - capabilities.acquire(uri.toString(), queryDisplayName(context.contentResolver, uri)) + capabilities.acquire( + selection.accountId, + uri.toString(), + queryDisplayName(context.contentResolver, uri), + ) } result.onSuccess { localRoot -> resumeFileSyncRootSelection(continuation, localRoot, capabilities::abandonSelection) @@ -57,7 +66,7 @@ internal class AndroidFileSyncRootPicker( } fun abandon(localRootId: String): Boolean = - runCatching { capabilities.abandonSelection(localRootId) }.getOrDefault(false) + abandonAndroidFileSyncRoot(localRootId, capabilities::abandonSelection) private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { val documentId = DocumentsContract.getTreeDocumentId(treeUri) @@ -74,6 +83,20 @@ internal class AndroidFileSyncRootPicker( } } +internal fun abandonAndroidFileSyncRoot( + localRootId: String, + abandonContentRoot: (String) -> Boolean, +): Boolean = if (localRootId.startsWith("content://")) { + runCatching { abandonContentRoot(localRootId) }.getOrDefault(false) +} else { + true +} + +private data class PendingFileSyncRootSelection( + val accountId: AndroidFileSyncCapabilityAccountId, + val continuation: CancellableContinuation, +) + internal fun resumeFileSyncRootSelection( continuation: CancellableContinuation, localRoot: FileSyncLocalRoot, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index e881448c9..e3096b12b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1674,12 +1674,21 @@ internal class AndroidNextcloudServices( freedBytes = freed, ) } - override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = + override suspend fun chooseFileSyncLocalRoot( + session: NextcloudSession, + initialRootHint: String?, + ): FileSyncLocalRoot? = checkNotNull(fileSyncRootPicker) { "The native folder chooser is not available from this Android component." } - .choose(initialRootHint) + .choose(AndroidFileSyncCapabilityAccountId(NextcloudDocumentIds.accountKey(session)), initialRootHint) override fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = fileSyncRootPicker?.abandon(localRoot.localRootId) ?: true override fun retainFileSyncRootOnDispose(): Boolean = activity?.isChangingConfigurations == true + override suspend fun reconcileFileSyncRootSetup( + session: NextcloudSession, + restoredLocalRoot: FileSyncLocalRoot?, + ) = withContext(Dispatchers.IO) { + reconcileRestoredFileSyncSetup(appContext, session, restoredLocalRoot) + } override suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index a7f9b2ac3..955ab0e3f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -12,7 +12,11 @@ import kotlin.coroutines.CoroutineContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine @@ -53,7 +57,7 @@ class AndroidFileSyncCapabilityLifecycleTest { fun `acquisition records intent before taking and ends ready`() { val fixture = fixture() - val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + val root = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") assertEquals(ROOT_URI, root.localRootId) assertEquals(listOf("query", "take", "query"), fixture.grants.events) @@ -63,7 +67,7 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `pre-existing exact grant is never taken or revoked`() { val fixture = fixture(readGranted = true, writeGranted = true) - val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + val root = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") assertTrue(fixture.lifecycle.abandonSelection(root.localRootId)) @@ -76,7 +80,7 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `cleanup releases only the permission mode acquired for sync`() { val fixture = fixture(readGranted = true) - val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + val root = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") assertTrue(fixture.lifecycle.abandonSelection(root.localRootId)) @@ -91,7 +95,7 @@ class AndroidFileSyncCapabilityLifecycleTest { fixture.grants.failQuery = true assertFailsWith { - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } assertEquals(listOf("query"), fixture.grants.events) @@ -101,11 +105,11 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `duplicate exact uri is rejected before a second grant is taken`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") fixture.grants.events.clear() assertFailsWith { - fixture.lifecycle.acquire(ROOT_URI, "Notes again") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes again") } assertEquals(listOf("query"), fixture.grants.events) @@ -117,6 +121,30 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(hasDuplicateAndroidFileSyncRoot(listOf(pair()), "other-account", ROOT_URI, "Archive")) } + @Test + fun `media root dismissal succeeds without touching saf capabilities`() { + var safAbandonCalls = 0 + + assertTrue(abandonAndroidFileSyncRoot("media-store://primary/DCIM/Camera") { + safAbandonCalls += 1 + false + }) + + assertEquals(0, safAbandonCalls) + } + + @Test + fun `content root dismissal still delegates to saf abandonment`() { + var safAbandonCalls = 0 + + assertFalse(abandonAndroidFileSyncRoot(ROOT_URI) { + safAbandonCalls += 1 + false + }) + + assertEquals(1, safAbandonCalls) + } + @Test fun `non-saf roots retain the existing per-account destination rule`() { val mediaPair = pair().copy(localRootId = "media-store://primary/DCIM/Camera") @@ -145,7 +173,7 @@ class AndroidFileSyncCapabilityLifecycleTest { fixture.storage.failWriteNumber = 2 assertFailsWith { - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } assertFalse(fixture.grants.readGranted) @@ -160,7 +188,7 @@ class AndroidFileSyncCapabilityLifecycleTest { fixture.storage.persistFailedWrite = true assertFailsWith { - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } assertEquals(listOf("query", "query"), fixture.grants.events) @@ -173,7 +201,7 @@ class AndroidFileSyncCapabilityLifecycleTest { fixture.storage.failWritesFrom = 2 assertFailsWith { - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } assertTrue(fixture.grants.readGranted) @@ -185,8 +213,8 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `pair cleanup is durable before release and retries a failed release`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") - fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) assertTrue(fixture.lifecycle.preparePairCleanup(PAIR_ID)) assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) @@ -204,7 +232,7 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `failed setup abandonment remains retryable without restart`() { val fixture = fixture() - val root = fixture.lifecycle.acquire(ROOT_URI, "Notes") + val root = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") fixture.grants.failReleaseCount = 1 assertFalse(fixture.lifecycle.abandonSelection(root.localRootId)) @@ -217,17 +245,93 @@ class AndroidFileSyncCapabilityLifecycleTest { } @Test - fun `prior process ready record is released when no pair owns it`() { + fun `prior process ready record waits for restored setup reconciliation`() { val fixture = fixture(generation = NEW_GENERATION) fixture.seedReady(OLD_GENERATION) fixture.lifecycle.reconcile(state()) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + + assertTrue(fixture.lifecycle.reconcileRestoredSetup(ACCOUNT_ID, null, state())) + assertFalse(fixture.grants.readGranted) assertFalse(fixture.grants.writeGranted) assertTrue(fixture.store.list().isEmpty()) } + @Test + fun `restored setup claim races startup reconcile and remains bindable`() = runBlocking { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + val restored = dev.obiente.nextcloudnative.app.FileSyncLocalRoot(ROOT_URI, "Notes") + val start = CompletableDeferred() + + listOf( + async(Dispatchers.Default) { + start.await() + fixture.lifecycle.reconcile(state()) + }, + async(Dispatchers.Default) { + start.await() + assertTrue( + fixture.lifecycle.reconcileRestoredSetup( + ACCOUNT_ID, + restored.localRootId, + state(), + ), + ) + }, + ).also { jobs -> + start.complete(Unit) + jobs.awaitAll() + } + + val claimed = fixture.store.list().single() + assertEquals(NEW_GENERATION, claimed.processGeneration) + assertEquals(ACCOUNT_ID, claimed.accountId) + fixture.lifecycle.bindReady(ACCOUNT_ID, restored.localRootId, PAIR_ID) + fixture.lifecycle.reconcile(state(pair())) + + assertEquals(setOf(PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `restored setup cannot claim another accounts ready capability`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + + assertFalse( + fixture.lifecycle.reconcileRestoredSetup( + AndroidFileSyncCapabilityAccountId("other-account"), + ROOT_URI, + state(), + ), + ) + + val retained = fixture.store.list().single() + assertEquals(ACCOUNT_ID, retained.accountId) + assertEquals(OLD_GENERATION, retained.processGeneration) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `legacy ownerless ready capability is not claimable`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION, accountId = null) + + assertFalse(fixture.lifecycle.reconcileRestoredSetup(ACCOUNT_ID, ROOT_URI, state())) + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + @Test fun `current process ready record remains available to the live setup ui`() { val fixture = fixture(generation = NEW_GENERATION) @@ -240,10 +344,34 @@ class AndroidFileSyncCapabilityLifecycleTest { assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) } + @Test + fun `current selection delivery is not cleaned by an empty restored snapshot`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(NEW_GENERATION) + + assertTrue(fixture.lifecycle.reconcileRestoredSetup(ACCOUNT_ID, null, state())) + + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `account retirement cleans a current selection`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(NEW_GENERATION) + + fixture.lifecycle.retireAccountSetup(ACCOUNT_ID, state()) + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + @Test fun `reselect before startup reconcile remains abandonable`() { val fixture = fixture(generation = NEW_GENERATION, readGranted = true, writeGranted = true) - val selection = fixture.lifecycle.acquire(ROOT_URI, "Notes again") + val selection = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes again") fixture.lifecycle.reconcile(state(pair())) @@ -408,6 +536,28 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(grants.events.isEmpty()) } + @Test + fun `malformed capability owner releases nothing`() { + val malformed = record(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Ready) + .toTestJson() + .put("accountId", 42) + val storage = FakeStorage(org.json.JSONArray().put(malformed).toString()) + val grants = FakeGrantAccess(readGranted = true, writeGranted = true) + val lifecycle = AndroidFileSyncCapabilityLifecycle( + AndroidFileSyncCapabilityStore(storage, IdentityCipher), + grants, + NEW_GENERATION, + ) + + assertFailsWith { + lifecycle.reconcileRestoredSetup(ACCOUNT_ID, ROOT_URI, state()) + } + + assertTrue(grants.readGranted) + assertTrue(grants.writeGranted) + assertTrue(grants.events.isEmpty()) + } + @Test fun `startup leaves grants unchanged when pair state is unreadable`() = runBlocking { val fixture = fixture(generation = NEW_GENERATION) @@ -429,8 +579,8 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `failed pair save retains ownership when authoritative reload contains the pair`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") - fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) recoverFailedFileSyncPairSave(PAIR_ID, { state(pair()) }, fixture.lifecycle::abandonUncommittedPair) @@ -442,7 +592,7 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `ambiguous bind failure reloads authoritative pairs and abandons uncommitted ownership`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") fixture.storage.failWriteNumber = fixture.storage.writes + 1 fixture.storage.persistFailedWrite = true var reloads = 0 @@ -451,7 +601,7 @@ class AndroidFileSyncCapabilityLifecycleTest { assertFailsWith { bindAndPersistFileSyncPair( pairId = PAIR_ID, - bindReady = { fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) }, + bindReady = { fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) }, persist = { pairPersisted = true }, load = { reloads += 1 @@ -471,8 +621,8 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `failed pair save releases ownership only when authoritative reload excludes the pair`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") - fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) recoverFailedFileSyncPairSave(PAIR_ID, { state() }, fixture.lifecycle::abandonUncommittedPair) @@ -484,8 +634,8 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `failed pair save retains ownership when authoritative reload is unreadable`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") - fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) recoverFailedFileSyncPairSave( PAIR_ID, @@ -501,8 +651,8 @@ class AndroidFileSyncCapabilityLifecycleTest { @Test fun `postcommit pair removal failure releases from the authoritative state immediately`() { val fixture = fixture() - fixture.lifecycle.acquire(ROOT_URI, "Notes") - fixture.lifecycle.bindReady(ROOT_URI, PAIR_ID) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) fixture.lifecycle.preparePairCleanup(PAIR_ID) assertFailsWith { @@ -554,8 +704,8 @@ class AndroidFileSyncCapabilityLifecycleTest { } private fun preparedCleanup(): Fixture = fixture().also { - it.lifecycle.acquire(ROOT_URI, "Notes") - it.lifecycle.bindReady(ROOT_URI, PAIR_ID) + it.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + it.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) it.lifecycle.preparePairCleanup(PAIR_ID) } @@ -589,8 +739,11 @@ class AndroidFileSyncCapabilityLifecycleTest { val grants: FakeGrantAccess, val lifecycle: AndroidFileSyncCapabilityLifecycle, ) { - fun seedReady(generation: String) { - store.add(record(generation, AndroidFileSyncCapabilityPhase.Ready)) + fun seedReady( + generation: String, + accountId: AndroidFileSyncCapabilityAccountId? = ACCOUNT_ID, + ) { + store.add(record(generation, AndroidFileSyncCapabilityPhase.Ready, accountId = accountId)) grants.readGranted = true grants.writeGranted = true } @@ -669,6 +822,17 @@ class AndroidFileSyncCapabilityLifecycleTest { override fun decrypt(value: String): String = error("cipher unavailable") } + private fun AndroidFileSyncCapabilityRecord.toTestJson() = org.json.JSONObject() + .put("id", id) + .put("uri", uri) + .put("displayName", displayName) + .put("phase", phase.name) + .put("processGeneration", processGeneration) + .put("preExistingReadGrant", preExistingReadGrant) + .put("preExistingWriteGrant", preExistingWriteGrant) + .put("accountId", accountId?.value) + .put("pairIds", org.json.JSONArray().also { array -> pairIds.forEach(array::put) }) + private class PausedDispatcher : CoroutineDispatcher() { private val tasks = ArrayDeque() @@ -683,6 +847,7 @@ class AndroidFileSyncCapabilityLifecycleTest { private companion object { const val ROOT_URI = "content://example.documents/tree/notes" + val ACCOUNT_ID = AndroidFileSyncCapabilityAccountId("account") val RECORD_ID: String = UUID.randomUUID().toString() val PAIR_ID: String = UUID.randomUUID().toString() val OTHER_PAIR_ID: String = UUID.randomUUID().toString() @@ -693,6 +858,7 @@ class AndroidFileSyncCapabilityLifecycleTest { generation: String, phase: AndroidFileSyncCapabilityPhase, pairIds: Set = emptySet(), + accountId: AndroidFileSyncCapabilityAccountId? = ACCOUNT_ID, ) = AndroidFileSyncCapabilityRecord( id = RECORD_ID, uri = ROOT_URI, @@ -701,6 +867,7 @@ class AndroidFileSyncCapabilityLifecycleTest { processGeneration = generation, preExistingReadGrant = false, preExistingWriteGrant = false, + accountId = accountId, pairIds = pairIds, ) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index cd9f95a5c..63eae52d1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt @@ -201,7 +201,7 @@ internal fun FileOfflineCenterScreen( syncBusyPairIds += ADD_PAIR_BUSY_ID scope.launch { try { - runCatching { services.chooseFileSyncLocalRoot() } + runCatching { services.chooseFileSyncLocalRoot(session) } .onSuccess { selected -> pendingMediaSuggestionJson = null pendingLocalRoot = selected @@ -472,6 +472,10 @@ internal fun FileOfflineCenterScreen( if (userId.isBlank() || !services.supportsBidirectionalFileSync) return@LaunchedEffect syncLoading = true try { + if (!services.reconcileFileSyncRootSetup(session, pendingLocalRoot)) { + setupDraft.clear() + actionMessage = "Select the local folder again to restore folder access." + } syncSnapshot = services.loadFileSyncCenter(session, userId) } catch (cancelled: CancellationException) { throw cancelled 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 dfe07d1c1..90aeabf1e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -932,11 +932,11 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Unsupported( "Selective virtual folders are not available on this platform.", ) - /** Opens the native folder chooser and persists a least-privilege folder grant. */ - suspend fun chooseFileSyncLocalRoot(initialRootHint: String? = null): FileSyncLocalRoot? = null + suspend fun chooseFileSyncLocalRoot(session: NextcloudSession, initialRootHint: String? = null): FileSyncLocalRoot? = null fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot): Boolean = true fun retainFileSyncRootOnDispose(): Boolean = false + suspend fun reconcileFileSyncRootSetup(session: NextcloudSession, restoredLocalRoot: FileSyncLocalRoot?): Boolean = true /** Lists durable share-sheet uploads that still need progress or user review. */ suspend fun loadIncomingShareRecoveries( session: NextcloudSession, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index b32a2cddc..8c1f1b128 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -2388,7 +2388,7 @@ class DesktopNextcloudServices( true } - override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = + override suspend fun chooseFileSyncLocalRoot(session: NextcloudSession, initialRootHint: String?): FileSyncLocalRoot? = fileSyncEngine.chooseLocalRoot(initialRootHint) override suspend fun loadFileSyncCenter( From 0cf9b0d1db058027f099e89b30e1f63e3945446b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:37:55 +0200 Subject: [PATCH 15/16] fix(android): confirm committed folder pair setup --- .../nextcloudnative/AndroidFileSyncEngine.kt | 3 +- .../AndroidFileSyncExecutionCoordination.kt | 32 ++++++++++--- .../AndroidNextcloudServices.kt | 16 ++----- .../AndroidFileSyncCapabilityLifecycleTest.kt | 47 +++++++++++++++++++ .../public/screenshots/capture-manifest.json | 4 +- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index fe07ea97b..2e146c1cb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -287,8 +287,7 @@ internal class AndroidFileSyncEngine(context: Context) { } else { store.save(updated) } - scheduler.schedule(pair.id, accountId, userId, pair.configuration) - FileSyncCenterActionResult.Completed("Folder sync pair added. Run it to review the first sync.") + committedFileSyncPairResult { scheduler.schedule(pair.id, accountId, userId, pair.configuration) } } private fun FileSyncConfiguration.scheduleDescription(): String { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 0bd281b72..bf90e7b63 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import android.content.Context import android.net.Uri +import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation import dev.obiente.nextcloudnative.app.FileSyncPair @@ -129,13 +130,14 @@ internal fun recoverFailedFileSyncPairSave( pairId: String, load: () -> AndroidFileSyncPersistedState, abandonUncommittedPair: (String) -> Unit, -) { - val commitIsAbsent = try { - load().coordinator.pairs.none { it.id == pairId } +): Boolean { + val commitIsPresent = try { + load().coordinator.pairs.any { it.id == pairId } } catch (_: Exception) { - false + return false } - if (commitIsAbsent) runCatching { abandonUncommittedPair(pairId) } + if (!commitIsPresent) runCatching { abandonUncommittedPair(pairId) } + return commitIsPresent } internal fun bindAndPersistFileSyncPair( @@ -149,11 +151,29 @@ internal fun bindAndPersistFileSyncPair( bindReady() persist() } catch (failure: Exception) { - recoverFailedFileSyncPairSave(pairId, load, abandonUncommittedPair) + if (recoverFailedFileSyncPairSave(pairId, load, abandonUncommittedPair)) return throw failure } } +internal fun scheduleCommittedFileSyncPair(schedule: () -> Unit): Boolean = try { + schedule() + true +} catch (failure: CancellationException) { + throw failure +} catch (_: Exception) { + false +} + +internal fun committedFileSyncPairResult(schedule: () -> Unit): FileSyncCenterActionResult { + val scheduled = scheduleCommittedFileSyncPair(schedule) + return FileSyncCenterActionResult.Completed(if (scheduled) { + "Folder sync pair added. Run it to review the first sync." + } else { + "Folder sync pair added. Automatic checks will retry when folder sync status is loaded." + }) +} + /** * Reads a complete atomic snapshot without waiting for active execution. * diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index e3096b12b..8f14878e3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1674,21 +1674,13 @@ internal class AndroidNextcloudServices( freedBytes = freed, ) } - override suspend fun chooseFileSyncLocalRoot( - session: NextcloudSession, - initialRootHint: String?, - ): FileSyncLocalRoot? = + override suspend fun chooseFileSyncLocalRoot(session: NextcloudSession, initialRootHint: String?): FileSyncLocalRoot? = checkNotNull(fileSyncRootPicker) { "The native folder chooser is not available from this Android component." } .choose(AndroidFileSyncCapabilityAccountId(NextcloudDocumentIds.accountKey(session)), initialRootHint) - override fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = - fileSyncRootPicker?.abandon(localRoot.localRootId) ?: true + override fun abandonFileSyncLocalRoot(localRoot: FileSyncLocalRoot) = fileSyncRootPicker?.abandon(localRoot.localRootId) ?: true override fun retainFileSyncRootOnDispose(): Boolean = activity?.isChangingConfigurations == true - override suspend fun reconcileFileSyncRootSetup( - session: NextcloudSession, - restoredLocalRoot: FileSyncLocalRoot?, - ) = withContext(Dispatchers.IO) { - reconcileRestoredFileSyncSetup(appContext, session, restoredLocalRoot) - } + override suspend fun reconcileFileSyncRootSetup(session: NextcloudSession, restoredLocalRoot: FileSyncLocalRoot?) = + withContext(Dispatchers.IO) { reconcileRestoredFileSyncSetup(appContext, session, restoredLocalRoot) } override suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt index 955ab0e3f..6e092b536 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncPair import java.util.UUID @@ -589,6 +590,52 @@ class AndroidFileSyncCapabilityLifecycleTest { assertTrue(fixture.grants.writeGranted) } + @Test + fun `save failure after authoritative commit completes without releasing ownership`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + var persisted = state() + + bindAndPersistFileSyncPair( + pairId = PAIR_ID, + bindReady = { fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) }, + persist = { + persisted = state(pair()) + error("save reported failure after commit") + }, + load = { persisted }, + abandonUncommittedPair = fixture.lifecycle::abandonUncommittedPair, + ) + + val owned = fixture.store.list().single() + assertEquals(listOf(PAIR_ID), persisted.coordinator.pairs.map(FileSyncPair::id)) + assertEquals(AndroidFileSyncCapabilityPhase.Owned, owned.phase) + assertEquals(setOf(PAIR_ID), owned.pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + } + + @Test + fun `postcommit scheduling failure is contained for durable retry`() { + var attempts = 0 + + val failedScheduleResult = committedFileSyncPairResult { + attempts += 1 + error("synthetic scheduling failure") + } + val scheduledResult = committedFileSyncPairResult { attempts += 1 } + + assertEquals(2, attempts) + assertEquals( + "Folder sync pair added. Automatic checks will retry when folder sync status is loaded.", + (failedScheduleResult as FileSyncCenterActionResult.Completed).message, + ) + assertEquals( + "Folder sync pair added. Run it to review the first sync.", + (scheduledResult as FileSyncCenterActionResult.Completed).message, + ) + } + @Test fun `ambiguous bind failure reloads authoritative pairs and abandons uncommitted ownership`() { val fixture = fixture() diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 37291b7c4..7a2dbae9f 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -516,7 +516,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt": "8b529e61c68ec3ee7937fc3695832284b0b7c88841893fe40d3921234b566e51", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "4a2e6340478da7d40dfe327af2ceebd750cdb47994fc17e9ee830ec37bd14d46", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "45bfcd7ef28ea73e5514cb3af715e274257609283ac7a857b32327fcff0070e2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueue.kt": "f97b055f4278c8dc7e5b3d4ad4284aaafd0194caf01368754daba9f94632ea24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "1282adb909c54d559d812689ca9f937cda3a1256c1400fd0d0f91ba3a1ace1d6", @@ -647,7 +647,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b712a992ee8aff25a7beaf954cae58bd0fda2946e35a650fa037b9dbdd8ee957", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "fa1dabfc1c4fee33c285ce9bb967cdf40b9fa20ba5d2896768d5c24a7d8f6dac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", From 6302932b433da3d4083fb70cbe8911cb84c69cc2 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:13:14 +0000 Subject: [PATCH 16/16] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 7a2dbae9f..ade9f8427 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -489,7 +489,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019",