diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 4aabc551f..9e9b3c513 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -192,8 +192,33 @@ Each boundary has a corresponding test responsibility: permission denial, confirmation, adaptive layout, and keyboard/touch access. - Platform tests cover credential stores, filesystem paths and providers, background scheduling, external handoff, packaging, and lifecycle recovery. + +Android folder capability cleanup uses demand-driven one-time WorkManager work. +Empty stores and committed pairs do not keep cleanup work alive. Outstanding +selections retain a retry owner until bound or abandoned; reconciliation preserves +selections already delivered to an open setup. +Folder-picker acquisition and durable scheduling run on the picker's owned IO +scope, with result delivery on Main and cancellation cleanup retained on IO. +New acquisitions and cleanup requests schedule recovery, and unfinished cleanup +retains bounded WorkManager backoff. +A process restoration grace period protects pending folder drafts only while +acquiring or ready selections remain after reconciliation; completed setup and +cleanup return without waiting. A cancelled pair save preserves authoritative +ownership recovery and then rethrows cancellation, even when the save committed. +Abandoned acquisitions and committed pair removals retain cleanup evidence until access +is released. Cleanup retries do not transfer or delete user file contents. - Live-server audits use synthetic disposable accounts, record exact tested versions, and remain separate from deterministic unit and integration tests. +A durably removed Android folder-sync pair reports completion while its previously +scheduled capability recovery worker retries any remaining permission cleanup. +Ambiguous coordinator saves still require authoritative confirmation of removal. +Account retirement remains strict until its capability cleanup finishes. +Cancellation from grant, storage, and cipher adapters remains cancellation rather +than being reported as damaged recovery metadata or deferred cleanup. +Reconciliation records independent cleanup progress before reporting a failed +provider, so one unavailable grant cannot indefinitely retain unrelated grants. +A legacy shared root can regain expired access only for an account that still +owns a recorded pair at that exact root in the authoritative coordinator. A bug fix adds the smallest regression test at the layer where the invariant failed. Tests should assert public behavior, not copied implementation details. 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..c66d66c18 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -0,0 +1,781 @@ +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 accountId: AndroidFileSyncCapabilityAccountId? = null, + 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. Folder permissions may still need recovery.", + 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) + +@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, + 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) { + if (failure is kotlinx.coroutines.CancellationException) throw failure + 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 kotlinx.coroutines.CancellationException) throw failure + 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) { + if (failure is kotlinx.coroutines.CancellationException) throw failure + throw IllegalStateException("Folder capability recovery data could not be encrypted.", failure) + } + val saved = try { + storage.write(encrypted) + } catch (failure: Exception) { + if (failure is kotlinx.coroutines.CancellationException) throw failure + 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, + private val abandonedSelections: MutableSet = linkedSetOf(), + private val deliveredSelections: MutableSet = linkedSetOf(), + private val requestRecovery: () -> Unit = {}, + private val requestedAbandonments: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet(), + private val loadConfiguredPairs: () -> List = { emptyList() }, +) { + constructor(context: Context) : this( + AndroidFileSyncCapabilityStore(context.applicationContext), + ContentResolverFileSyncGrantAccess(context.applicationContext.contentResolver), + PROCESS_GENERATION, + ABANDONED_SELECTIONS, + DELIVERED_SELECTIONS, + { requestAndroidFileSyncCapabilityRecovery(context.applicationContext) }, + REQUESTED_ABANDONMENTS, + { AndroidFileSyncStore(context.applicationContext).load().coordinator.pairs }, + ) + + fun hasRecoveryWork(): Boolean = synchronized(LIFECYCLE_LOCK) { + store.list().any { record -> + record.phase == AndroidFileSyncCapabilityPhase.Acquiring || + record.phase == AndroidFileSyncCapabilityPhase.CleanupPending || + record.phase == AndroidFileSyncCapabilityPhase.Ready + } + } + + fun hasRestorableSetup(): Boolean = synchronized(LIFECYCLE_LOCK) { + store.list().any { it.phase == AndroidFileSyncCapabilityPhase.Acquiring || it.phase == AndroidFileSyncCapabilityPhase.Ready } + } + + fun acquire( + accountId: AndroidFileSyncCapabilityAccountId, + exactUri: String, + displayName: String, + ): FileSyncLocalRoot = synchronized(LIFECYCLE_LOCK) { + val preExisting = grants.exactGrant(exactUri) + var existing = store.list().singleOrNull { it.uri == exactUri } + if (existing == null) { + val legacyPairs = loadConfiguredPairs().filter { it.localRootId == exactUri } + if (legacyPairs.isNotEmpty()) { + require(legacyPairs.any { it.accountId == accountId.value }) { "This folder belongs to another account." } + val adopted = AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), uri = exactUri, displayName = displayName, + phase = AndroidFileSyncCapabilityPhase.Owned, processGeneration = processGeneration, + preExistingReadGrant = false, preExistingWriteGrant = false, + accountId = legacyPairs.singleAccountOwner(), pairIds = legacyPairs.mapTo(linkedSetOf(), FileSyncPair::id), + ) + store.add(adopted) + existing = adopted + } + } + val retained = existing + val ownsExisting = retained?.accountId == accountId || + (retained?.phase == AndroidFileSyncCapabilityPhase.Owned && retained.accountId == null && + loadConfiguredPairs().any { pair -> + pair.id in retained.pairIds && pair.localRootId == exactUri && pair.accountId == accountId.value + }) + if (retained?.phase == AndroidFileSyncCapabilityPhase.Owned && ownsExisting && + (!preExisting.read || !preExisting.write) + ) { + store.replace(retained.id, retained.phase) { + it.copy( + preExistingReadGrant = it.preExistingReadGrant && preExisting.read, + preExistingWriteGrant = it.preExistingWriteGrant && preExisting.write, + ) + } + grants.takeExactReadWriteGrant(exactUri) + val restored = grants.exactGrant(exactUri) + check(restored.read && restored.write) { "The folder provider did not restore read and write access." } + return@synchronized FileSyncLocalRoot(exactUri, displayName, retained.id, accessRestored = true) + } + val record = AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), + uri = exactUri, + displayName = displayName, + phase = AndroidFileSyncCapabilityPhase.Acquiring, + processGeneration = processGeneration, + preExistingReadGrant = preExisting.read, + preExistingWriteGrant = preExisting.write, + accountId = accountId, + ) + try { + requestRecovery() + 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) + } + deliveredSelections += record.id + FileSyncLocalRoot(exactUri, displayName, savedStateId = record.id) + } catch (failure: Exception) { + recoverAcquisition(record.id) + throw failure + } + } + + fun restoreSelection(accountId: AndroidFileSyncCapabilityAccountId, reference: FileSyncLocalRoot): FileSyncLocalRoot? = + synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + it.id == reference.savedStateId && it.accountId == accountId && + it.phase == AndroidFileSyncCapabilityPhase.Ready + } ?: return@synchronized null + val grant = grants.exactGrant(record.uri) + if (!grant.read || !grant.write) return@synchronized null + store.replace(record.id, record.phase) { it.copy(processGeneration = processGeneration) } + deliveredSelections += record.id + FileSyncLocalRoot(record.uri, record.displayName, savedStateId = record.id) + } + + fun bindReady( + accountId: AndroidFileSyncCapabilityAccountId, + localRootId: String, + pairId: String, + ) = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + 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)) + } + } + + fun requestSelectionAbandonment(reference: String) { + requestedAbandonments += reference + } + + fun abandonSelection(localRootId: String): Boolean { + requestSelectionAbandonment(localRootId) + return synchronized(LIFECYCLE_LOCK) { + val records = store.list() + if (records.none { it.uri == localRootId || it.id == localRootId }) { + requestedAbandonments.remove(localRootId) + return@synchronized true + } + val record = records.singleOrNull { + (it.uri == localRootId || it.id == localRootId) && + it.pairIds.isEmpty() && + it.phase in setOf( + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.CleanupPending, + ) + } ?: return@synchronized false + requestRecovery() + deliveredSelections.remove(record.id) + abandonedSelections += record.id + prepareAndFinishCleanup(record) + } + } + + fun abandonUncommittedPair(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + requestRecovery() + val record = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.Owned + } ?: return@synchronized false + val retainedIds = record.pairIds - pairId + val updated = store.replace(record.id, record.phase) { + it.copy( + phase = if (retainedIds.isEmpty()) AndroidFileSyncCapabilityPhase.CleanupPending else record.phase, + pairIds = retainedIds, + ) + } + if (retainedIds.isEmpty()) finishCleanup(updated) else true + } + + fun preparePairCleanup(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + requestRecovery() + 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 finishPairCleanupOrRetry( + pairId: String, + allowDeferredCleanup: Boolean = false, + load: () -> AndroidFileSyncPersistedState, + ) { + try { + synchronized(LIFECYCLE_LOCK) { + val pending = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.CleanupPending + } ?: return + if (finishCleanup(pending)) return + reconcile(load()) + } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (!allowDeferredCleanup) throw failure + if (load().coordinator.pairs.any { it.id == pairId }) throw failure + // Removal is committed. The recovery owner was scheduled before the + // cleanup intent and retains the remaining grant cleanup across restart. + } + } + + fun persistPairRemoval( + pairId: String, + load: () -> AndroidFileSyncPersistedState, + persist: () -> Unit, + ) = try { + persist() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (!recoverAmbiguousPairRemoval(pairId, load)) throw failure + Unit + } + + private fun recoverAmbiguousPairRemoval(pairId: String, load: () -> AndroidFileSyncPersistedState): Boolean = synchronized(LIFECYCLE_LOCK) { + val authoritative = try { + load() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + return@synchronized false + } + if (authoritative.coordinator.pairs.any { it.id == pairId }) return@synchronized false + try { + reconcile(authoritative) + true + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + // Confirmed removal is complete even if its durable grant cleanup is pending. + true + } + } + + fun reconcile(state: AndroidFileSyncPersistedState, reclaimUnrestoredReady: Boolean = false) = synchronized(LIFECYCLE_LOCK) { + var records = store.list() + abandonedSelections.retainAll(records.map { it.id }.toSet()) + deliveredSelections.retainAll(records.map { it.id }.toSet()) + requestedAbandonments.retainAll(records.flatMap { listOf(it.id, it.uri) }.toSet()) + records.filter { it.id in requestedAbandonments || it.uri in requestedAbandonments } + .forEach { abandonedSelections += it.id } + val safPairs = state.coordinator.pairs.filter { it.localRootId.startsWith("content://") } + check(!hasConflictingOwnership(records, safPairs)) { + "Folder capability ownership must be reconciled before changing sync pairs." + } + var recoveryFailure: Exception? = null + fun attemptRecovery(action: () -> Unit) { + try { + action() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (recoveryFailure == null) recoveryFailure = failure + } + } + safPairs.groupBy(FileSyncPair::localRootId).forEach { (uri, matches) -> + attemptRecovery { + if (records.none { it.uri == uri }) adoptLegacyCapability(uri, matches, state.localDisplayNames) + } + } + records = store.list() + records.forEach { original -> attemptRecovery { + val record = store.list().firstOrNull { it.id == original.id } ?: return@attemptRecovery + val matchingPairs = safPairs.filter { it.localRootId == record.uri } + val matchingIds = matchingPairs.mapTo(linkedSetOf(), FileSyncPair::id) + when (record.phase) { + AndroidFileSyncCapabilityPhase.Acquiring -> if (record.processGeneration != processGeneration || reclaimUnrestoredReady) { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy( + phase = AndroidFileSyncCapabilityPhase.Owned, + accountId = matchingPairs.singleAccountOwner(), + pairIds = matchingIds, + ) + } + } else { + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + } + AndroidFileSyncCapabilityPhase.Ready -> if (record.processGeneration != processGeneration || record.id in abandonedSelections || + (reclaimUnrestoredReady && record.id !in deliveredSelections) + ) { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy( + phase = AndroidFileSyncCapabilityPhase.Owned, + accountId = matchingPairs.singleAccountOwner(), + pairIds = matchingIds, + ) + } + } else if (record.accountId == null || reclaimUnrestoredReady || record.id in abandonedSelections) { + 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) { + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + } + AndroidFileSyncCapabilityPhase.CleanupPending -> { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Owned, pairIds = matchingIds) + } + } else { + check(finishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + } + } + } } + recoveryFailure?.let { throw it } + Unit + } + + + 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) deliveredSelections += restored.id + 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 { pair -> + pair.id == pairId && + (pair.localRootId != record.uri || + record.accountId?.value?.let { owner -> owner != pair.accountId } == true) + } + } + } + + 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, + accountId = pairs.singleAccountOwner(), + pairIds = pairIds, + )) + } + + private fun recoverAcquisition(recordId: String) { + val record = try { + store.list().singleOrNull { it.id == recordId } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + null + } ?: return + try { + prepareAndFinishCleanup(record) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + // The durable recovery owner retains this incomplete acquisition. + } + } + + 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 (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + return false + } + if ((ownedRead && granted.read) || (ownedWrite && granted.write)) { + try { + grants.releaseExactGrant(record.uri, ownedRead, ownedWrite) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + return false + } + val retained = try { + grants.exactGrant(record.uri) + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } 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 (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + try { + store.list().none { it.id == record.id } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { false } + }.also { removed -> + if (removed) { + abandonedSelections.remove(record.id) + requestedAbandonments.remove(record.id) + requestedAbandonments.remove(record.uri) + } + } + } + + private companion object { + val LIFECYCLE_LOCK = Any() + val ABANDONED_SELECTIONS = linkedSetOf() + val DELIVERED_SELECTIONS = linkedSetOf() + val REQUESTED_ABANDONMENTS: MutableSet = java.util.concurrent.ConcurrentHashMap.newKeySet() + 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("accountId", accountId?.value) + .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"), + accountId = optionalCapabilityAccountId(), + 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 +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/AndroidFileSyncCapabilityRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityRecoveryWork.kt new file mode 100644 index 000000000..2d276e3cc --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityRecoveryWork.kt @@ -0,0 +1,84 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal fun startAndroidFileSyncCapabilityRecovery( + context: Context, + scope: CoroutineScope, + load: () -> AndroidFileSyncPersistedState, + capabilities: AndroidFileSyncCapabilityLifecycle, +) { + scope.launch { + try { + // Retire the old unconditional periodic schedule after upgrading. + WorkManager.getInstance(context).cancelUniqueWork("file-sync-capability-cleanup-v1").await() + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + capabilities.reconcile(load()) + if (capabilities.hasRecoveryWork()) requestAndroidFileSyncCapabilityRecovery(context) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + // A failed immediate cleanup must still have a durable retry owner. + try { + requestAndroidFileSyncCapabilityRecovery(context) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (schedulingFailure: Exception) { + failure.addSuppressed(schedulingFailure) + } + android.util.Log.w("FolderCapabilityRecovery", "Folder access cleanup is awaiting recovery.", failure) + } + } +} + +internal fun requestAndroidFileSyncCapabilityRecovery(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.MINUTES) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES) + .build() + // Append preserves a new request arriving while the previous worker finishes. + val operation = WorkManager.getInstance(context).enqueueUniqueWork( + "file-sync-capability-cleanup-v2", ExistingWorkPolicy.APPEND_OR_REPLACE, request, + ) + // This boundary runs on the owned IO path, never the Activity result callback. + runBlocking { withTimeout(30_000L) { operation.await() } } +} + +internal class AndroidFileSyncCapabilityRecoveryWorker(context: Context, parameters: WorkerParameters) : + CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val capabilities = AndroidFileSyncCapabilityLifecycle(applicationContext) + val store = AndroidFileSyncStore(applicationContext) + // A worker may start the process before the activity restores its draft. + reconcileFileSyncCapabilitiesAfterRestoration( + AndroidFileSyncEngine.ENGINE_LOCK, store::loadAndReconcileUploadCleanups, capabilities, + onFailure = { throw it }, + ) + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + if (capabilities.hasRecoveryWork()) Result.retry() else Result.success() + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + Result.retry() + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 06cb6de16..727657ed2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -91,6 +91,9 @@ 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) + private val loadCapabilityState = store::loadAndReconcileUploadCleanups + init { startAndroidFileSyncCapabilityRecovery(appContext, reconciliationScope, loadCapabilityState, capabilities) } suspend fun loadCenter( session: NextcloudSession, @@ -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,14 +272,22 @@ internal class AndroidFileSyncEngine(context: Context) { remoteRootPath = normalizedRemote, configuration = configuration, ) - store.save( - current.copy( - coordinator = addFileSyncPair(current.coordinator, pair), - localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), - ), + val updated = current.copy( + coordinator = addFileSyncPair(current.coordinator, pair), + localDisplayNames = current.localDisplayNames + (pair.id to localRoot.displayName), ) - scheduler.schedule(pair.id, accountId, userId, pair.configuration) - FileSyncCenterActionResult.Completed("Folder sync pair added. Run it to review the first sync.") + if (localRoot.localRootId.startsWith("content://")) { + bindAndPersistFileSyncPair( + pairId = pair.id, + bindReady = { capabilities.bindReady(AndroidFileSyncCapabilityAccountId(accountId), localRoot.localRootId, pair.id) }, + persist = { store.save(updated) }, + load = store::load, + abandonUncommittedPair = capabilities::abandonUncommittedPair, + ) + } else { + store.save(updated) + } + committedFileSyncPairResult { scheduler.schedule(pair.id, accountId, userId, pair.configuration) } } private fun FileSyncConfiguration.scheduleDescription(): String { @@ -312,8 +318,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,18 +356,14 @@ internal class AndroidFileSyncEngine(context: Context) { } }, persistRemoval = { + capabilities.preparePairCleanup(pairId) val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) - store.save( - current.copy( - coordinator = remaining, - localDisplayNames = current.localDisplayNames - pairId, - ), - ) + capabilities.persistPairRemoval(pairId, store::loadAndReconcileUploadCleanups) { + store.save(current.copy(coordinator = remaining, localDisplayNames = current.localDisplayNames - pairId)) + } }, cancelSchedule = { scheduler.cancel(pairId) }, - releaseLocalGrant = { - releaseSafGrantAfterPairRemoval(appContext, pair.localRootId, releasesLocalGrant) - }, + releaseLocalGrant = { capabilities.finishPairCleanupOrRetry(pairId, allowDeferredCleanup = true, load = 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 ed2e2125b..49666bdaa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -1,8 +1,8 @@ package dev.obiente.nextcloudnative import android.content.Context -import android.content.Intent 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 @@ -98,6 +98,102 @@ internal fun deferFileSyncSnapshotActionUntilIdle( return job } +internal suspend fun reconcileFileSyncCapabilities( + lock: Mutex, + load: () -> AndroidFileSyncPersistedState, + capabilities: AndroidFileSyncCapabilityLifecycle, + reclaimUnrestoredReady: Boolean = false, + onFailure: (Exception) -> Unit = {}, +) { + lock.withLock { + try { + capabilities.reconcile(load(), reclaimUnrestoredReady) + } catch (failure: CancellationException) { + throw failure + } catch (failure: Exception) { + // Callers with a durable retry owner propagate failure; UI recovery can defer it. + onFailure(failure) + } + } +} + +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, + abandonUncommittedPair: (String) -> Boolean, +): Boolean { + val commitIsPresent = try { + load().coordinator.pairs.any { it.id == pairId } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return false + } + if (!commitIsPresent) check(abandonUncommittedPair(pairId)) { + "The saved folder access still needs cleanup. Retry closing the folder setup." + } + return commitIsPresent +} + +internal fun bindAndPersistFileSyncPair( + pairId: String, + bindReady: () -> Unit, + persist: () -> Unit, + load: () -> AndroidFileSyncPersistedState, + abandonUncommittedPair: (String) -> Boolean, +) { + try { + bindReady() + persist() + } catch (failure: Exception) { + val committed = try { + recoverFailedFileSyncPairSave(pairId, load, abandonUncommittedPair) + } catch (cancelled: CancellationException) { + if (failure is CancellationException) { + if (cancelled !== failure) failure.addSuppressed(cancelled) + throw failure + } + cancelled.addSuppressed(failure) + throw cancelled + } catch (cleanupFailure: Exception) { + failure.addSuppressed(cleanupFailure) + false + } + if (committed && failure !is CancellationException) 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. * @@ -212,38 +308,21 @@ 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 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) val notifications = AndroidNotificationCoordinator(context) retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, - retainedPairs = retainedPairs, reconcileLocalDownloads = { pair -> reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) }, @@ -251,22 +330,30 @@ 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 = { pairId -> capabilities.finishPairCleanupOrRetry(pairId, load = store::load) }, ) } } +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, - 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)) { @@ -274,21 +361,20 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( } currentCoroutineContext().ensureActive() } + withContext(NonCancellable) { + retiredPairs.forEach { pair -> prepareLocalGrantCleanup(pair.id) } + } + currentCoroutineContext().ensureActive() + retiredPairs.forEach { pair -> cancelSchedule(pair) cancelNotification(pair) } currentCoroutineContext().ensureActive() - val retainedLocalRoots = retainedPairs.mapTo(hashSetOf()) { pair -> pair.localRootId } - val releasedLocalRoots = retiredPairs.asSequence() - .map { pair -> pair.localRootId } - .filter { localRootId -> localRootId.startsWith("content://") && localRootId !in retainedLocalRoots } - .distinct() - .toList() withContext(NonCancellable) { - releasedLocalRoots.forEach { localRootId -> releaseLocalGrant(localRootId) } persistRetirement() + retiredPairs.forEach { pair -> finishLocalGrantCleanup(pair.id) } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisition.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisition.kt new file mode 100644 index 000000000..8f83d4453 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisition.kt @@ -0,0 +1,56 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The picker owns an IO scope; only continuation delivery runs on Main. */ +internal fun acquireFileSyncRootForDelivery( + scope: CoroutineScope, + continuation: CancellableContinuation, + acquire: () -> FileSyncLocalRoot, + abandon: (String) -> Unit, + mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) = scope.launch { + if (!continuation.isActive) return@launch + var acquired: FileSyncLocalRoot? = null + var delivered = false + try { + val root = acquire().also { acquired = it } + withContext(mainDispatcher) { + resumeFileSyncRootSelection(continuation, root) { undelivered -> + // Cancellation may arrive after this acquisition job completed. + // The same owner retains cleanup on IO even if its parent is cancelled. + scope.launch(NonCancellable) { reclaimUndeliveredFileSyncRoot(root.savedStateId ?: undelivered, abandon) } + } + delivered = true + } + } catch (cancelled: CancellationException) { + continuation.cancel(cancelled) + throw cancelled + } catch (failure: Exception) { + continuation.cancel(failure) + } finally { + val retained = acquired + if (!delivered && retained != null) { + withContext(NonCancellable) { reclaimUndeliveredFileSyncRoot(retained.savedStateId ?: retained.localRootId, abandon) } + } + } +} + +internal fun reclaimUndeliveredFileSyncRoot(root: String, abandon: (String) -> Unit) { + try { + abandon(root) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // Acquisition durably scheduled recovery before taking the grant. A failed + // immediate cleanup remains owned by that worker and its persisted record. + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index 81509c2f4..b5718ff95 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -2,11 +2,15 @@ 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 import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume @@ -17,41 +21,66 @@ 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( + context: Context, + private val capabilities: AndroidFileSyncCapabilityLifecycle = AndroidFileSyncCapabilityLifecycle(context), +) { + private val acquisitionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val appContext = context.applicationContext 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 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)) + acquireFileSyncRootForDelivery( + scope = acquisitionScope, + continuation = continuation, + acquire = { + capabilities.acquire( + selection.accountId, + uri.toString(), + queryDisplayName(appContext.contentResolver, uri), + ) + }, + abandon = capabilities::abandonSelection, + ) + } + + fun abandon(root: FileSyncLocalRoot): Boolean { + if (root.savedStateId == null && !root.localRootId.startsWith("content://")) return true + val reference = root.savedStateId ?: root.localRootId + capabilities.requestSelectionAbandonment(reference) + acquisitionScope.launch(NonCancellable) { + reclaimUndeliveredFileSyncRoot(reference, capabilities::abandonSelection) } - result.onSuccess(continuation::resume) - .onFailure { continuation.cancel(it) } + return true } private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { @@ -68,3 +97,27 @@ internal class AndroidFileSyncRootPicker(private val context: Context) { }.orEmpty().ifBlank { "Selected folder" } } } + +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, + abandon: (String) -> Unit, +) { + continuation.resume(localRoot) { _, undeliveredRoot, _ -> + runCatching { abandon(undeliveredRoot.localRootId) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncSetupRestoration.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncSetupRestoration.kt new file mode 100644 index 000000000..bd291dea1 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncSetupRestoration.kt @@ -0,0 +1,33 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal suspend fun restoreAndroidFileSyncRoot(context: Context, session: NextcloudSession, reference: FileSyncLocalRoot) = + withContext(Dispatchers.IO) { + if (reference.savedStateId == null) return@withContext reference + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + AndroidFileSyncCapabilityLifecycle(context).restoreSelection( + AndroidFileSyncCapabilityAccountId(NextcloudDocumentIds.accountKey(session)), reference, + ) + } + } + +internal suspend fun reconcileFileSyncCapabilitiesAfterRestoration( + lock: Mutex, + load: () -> AndroidFileSyncPersistedState, + capabilities: AndroidFileSyncCapabilityLifecycle, + onFailure: (Exception) -> Unit = {}, + waitForRestoration: suspend () -> Unit = { delay(60_000L) }, +) { + reconcileFileSyncCapabilities(lock, load, capabilities, onFailure = onFailure) + if (!lock.withLock { capabilities.hasRestorableSetup() }) return + waitForRestoration() + reconcileFileSyncCapabilities(lock, load, capabilities, reclaimUnrestoredReady = true, onFailure = onFailure) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index e4f5acbff..899c7efd6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -11,7 +11,6 @@ import java.io.DataInputStream import java.io.DataOutputStream import java.io.EOFException import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream import java.nio.charset.StandardCharsets import java.nio.file.AtomicMoveNotSupportedException @@ -58,15 +57,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 @@ -76,7 +74,7 @@ internal class AndroidFileSyncStore internal constructor( throw IllegalStateException("Folder sync state exceeds its safe storage limit.") } val stored = try { - DataInputStream(BufferedInputStream(FileInputStream(stateFile))).use { input -> + DataInputStream(BufferedInputStream(Files.newInputStream(stateFile.toPath()))).use { input -> check(input.readInt() == MAGIC) { "Folder sync state has an invalid header." } check(input.readInt() == FORMAT_VERSION) { "Folder sync state version is unsupported." } val snapshotLength = input.readInt() @@ -123,6 +121,15 @@ internal class AndroidFileSyncStore internal constructor( ) } + @Synchronized + fun loadAndReconcileUploadCleanups(): AndroidFileSyncPersistedState = load().also { state -> + if (stateFile.isFile) { + 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/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..8087fdbec 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -504,7 +504,6 @@ internal class AndroidNextcloudServices( supportsSeekableRemoteStreaming = true, ), ) - override fun platformCapabilities(): List = platformCapabilities.statuses() override fun requestPlatformCapability(capability: PlatformCapability): Boolean = @@ -1674,12 +1673,14 @@ 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 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) ?: true + override suspend fun restoreFileSyncLocalRoot(session: NextcloudSession, reference: FileSyncLocalRoot) = restoreAndroidFileSyncRoot(appContext, session, reference) + 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, @@ -1688,7 +1689,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/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( 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..d4d82bd7a --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncAccountRetirementCapabilityTest.kt @@ -0,0 +1,279 @@ +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 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) + 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) + } + + @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) + } + + @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, + persist: suspend () -> Unit, + ) { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + prepareLocalGrantCleanup = { pairId -> lifecycle.preparePairCleanup(pairId) }, + persistRetirement = persist, + finishLocalGrantCleanup = { pairId -> + lifecycle.finishPairCleanupOrRetry(pairId) { state(emptyList()) } + }, + ) + } + + 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 + 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 + } + } + + 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/AndroidFileSyncCapabilityCancellationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityCancellationTest.kt new file mode 100644 index 000000000..fe3b7764b --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityCancellationTest.kt @@ -0,0 +1,91 @@ +package dev.obiente.nextcloudnative + +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlinx.coroutines.CancellationException + +class AndroidFileSyncCapabilityCancellationTest { + @Test + fun storageAndCipherCancellationAreNotWrappedAsRecoveryFailures() { + for (stage in listOf("read", "decrypt", "encrypt", "write")) { + val fixture = Fixture() + fixture.cancelAt = stage + val failure = assertFailsWith { + if (stage == "read" || stage == "decrypt") fixture.store.list() + else fixture.store.remove(fixture.record.id, AndroidFileSyncCapabilityPhase.CleanupPending) + } + assertSame(fixture.cancellation, failure) + } + } + + @Test + fun cleanupPropagatesCancellationFromEveryGrantAndPersistenceBoundary() { + for (stage in listOf("query", "release", "verify", "read", "decrypt", "encrypt", "write", "fallback")) { + val fixture = Fixture() + fixture.cancelAt = stage + val failure = assertFailsWith { + fixture.lifecycle.finishPairCleanupOrRetry(PAIR_ID, allowDeferredCleanup = true) { + AndroidFileSyncPersistedState() + } + } + assertSame(fixture.cancellation, failure) + } + } + + private class Fixture { + var cancelAt: String? = null + val cancellation = CancellationException("synthetic cancellation") + private var encrypted: String? = null + private var released = false + private var queries = 0 + private var removalWriteFailed = false + private fun checkpoint(stage: String) { if (cancelAt == stage) throw cancellation } + val store = AndroidFileSyncCapabilityStore( + object : AndroidFileSyncCapabilityEncryptedStorage { + override fun read(): String? { + checkpoint("read") + if (cancelAt == "fallback" && removalWriteFailed) throw cancellation + return encrypted + } + override fun write(value: String): Boolean { + checkpoint("write") + if (cancelAt == "fallback") { removalWriteFailed = true; error("synthetic write failure") } + encrypted = value + return true + } + }, + object : AndroidFileSyncCapabilityCipher { + override fun encrypt(value: String): String { checkpoint("encrypt"); return value } + override fun decrypt(value: String): String { checkpoint("decrypt"); return value } + }, + ) + val record = AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), uri = "content://example.documents/tree/folder", + displayName = "Folder", phase = AndroidFileSyncCapabilityPhase.CleanupPending, + processGeneration = UUID.randomUUID().toString(), preExistingReadGrant = false, + preExistingWriteGrant = false, pairIds = setOf(PAIR_ID), + ).also(store::add) + val lifecycle = AndroidFileSyncCapabilityLifecycle( + store, + object : AndroidFileSyncGrantAccess { + override fun exactGrant(uri: String): AndroidFileSyncGrantState { + queries += 1 + checkpoint(if (queries == 1) "query" else "verify") + return AndroidFileSyncGrantState(!released, !released) + } + override fun takeExactReadWriteGrant(uri: String) = Unit + override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { + checkpoint("release") + released = true + } + }, + record.processGeneration, + ) + } + + private companion object { + const val PAIR_ID = "00000000-0000-0000-0000-000000000001" + } +} 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..0c17fc5f7 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -0,0 +1,1183 @@ +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 +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.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 +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(ACCOUNT_ID, 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 `grace period reclaims ambiguously committed Ready but retains delivered setup`() { + val abandoned = fixture() + abandoned.storage.failWritesFrom = 2 + abandoned.storage.persistOnlyWriteNumber = 2 + assertFailsWith { abandoned.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } + assertEquals(AndroidFileSyncCapabilityPhase.Ready, abandoned.store.list().single().phase) + abandoned.storage.failWritesFrom = null + abandoned.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertTrue(abandoned.store.list().isEmpty()) + + val delivered = fixture() + delivered.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + delivered.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, delivered.store.list().single().phase) + assertTrue(delivered.lifecycle.hasRecoveryWork()) + } + + @Test + fun `recovery stops after cleanup and empty stores need no worker`() { + val fixture = fixture() + assertFalse(fixture.lifecycle.hasRecoveryWork()) + fixture.seedReady(OLD_GENERATION) + assertTrue(fixture.lifecycle.hasRecoveryWork()) + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertFalse(fixture.lifecycle.hasRecoveryWork()) + } + + @Test + fun `new acquisition and abandonment request recovery without idle polling`() { + var requests = 0 + val fixture = fixture(requestRecovery = { requests += 1 }) + assertEquals(0, requests) + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + assertEquals(1, requests) + fixture.grants.failRelease = true + assertFalse(fixture.lifecycle.abandonSelection(ROOT_URI)) + assertEquals(2, requests) + assertTrue(fixture.lifecycle.hasRecoveryWork()) + } + + @Test + fun `ambiguous removal does not claim success for a retained or unreadable pair`() { + val fixture = preparedCleanup() + assertFailsWith { + fixture.lifecycle.persistPairRemoval(PAIR_ID, load = { state(pair()) }) { error("uncommitted") } + } + assertTrue(fixture.grants.readGranted) + assertFailsWith { + fixture.lifecycle.persistPairRemoval(PAIR_ID, load = { error("unreadable") }) { error("unknown") } + } + assertTrue(fixture.grants.readGranted) + } + + @Test + fun `UI abandonment marker lets the durable worker reclaim a delivered setup`() { + val fixture = fixture() + val root = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.requestSelectionAbandonment(checkNotNull(root.savedStateId)) + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.lifecycle.hasRecoveryWork()) + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + } + + @Test + fun `late cancellation cannot abandon a replacement selection at the same URI`() { + val fixture = fixture() + val original = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.requestSelectionAbandonment(checkNotNull(original.savedStateId)) + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + val replacement = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + assertTrue(original.savedStateId != replacement.savedStateId) + assertTrue(fixture.lifecycle.abandonSelection(checkNotNull(original.savedStateId))) + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertEquals(replacement.savedStateId, fixture.store.list().single().id) + assertTrue(fixture.grants.readGranted && fixture.grants.writeGranted) + } + + @Test + fun `expired owned grant can be reauthorized without creating another pair`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) + val owner = fixture.store.list().single() + fixture.grants.readGranted = false + fixture.grants.writeGranted = false + val restored = fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + assertTrue(restored.accessRestored) + assertTrue(fixture.grants.readGranted && fixture.grants.writeGranted) + assertEquals(owner, fixture.store.list().single()) + } + + @Test + fun `completed setup and cleanup skip the restoration grace period`() = runBlocking { + for (scenario in listOf("empty", "owned", "cleanup")) { + val fixture = fixture() + val persisted = if (scenario == "owned") state(pair()) else state() + if (scenario != "empty") { + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) + if (scenario == "cleanup") fixture.lifecycle.preparePairCleanup(PAIR_ID) + } + reconcileFileSyncCapabilitiesAfterRestoration(Mutex(), { persisted }, fixture.lifecycle) { + error("Completed $scenario recovery must not wait for restoration") + } + assertFalse(fixture.lifecycle.hasRestorableSetup()) + assertFalse(fixture.lifecycle.hasRecoveryWork()) + if (scenario == "owned") assertTrue(fixture.grants.readGranted && fixture.grants.writeGranted) + else assertTrue(fixture.store.list().isEmpty()) + } + } + + @Test + fun `restoration window reclaims unclaimed ready grants without requiring a screen`() = runBlocking { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + reconcileFileSyncCapabilitiesAfterRestoration(Mutex(), { state() }, fixture.lifecycle) { + assertEquals(AndroidFileSyncCapabilityPhase.Ready, fixture.store.list().single().phase) + } + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted || fixture.grants.writeGranted) + } + + @Test + fun `restoration deadline reclaims a failed current process acquisition`() = runBlocking { + val fixture = fixture() + fixture.storage.failWritesFrom = 2 + assertFailsWith { fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } + assertEquals(AndroidFileSyncCapabilityPhase.Acquiring, fixture.store.list().single().phase) + fixture.storage.failWritesFrom = null + reconcileFileSyncCapabilitiesAfterRestoration(Mutex(), { state() }, fixture.lifecycle) { + assertTrue(fixture.grants.readGranted) + } + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted || fixture.grants.writeGranted) + } + + @Test + fun `failed abandonment intent survives until the current process can persist cleanup`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.storage.failWritesFrom = fixture.storage.writes + 1 + assertFailsWith { fixture.lifecycle.abandonSelection(ROOT_URI) } + fixture.storage.failWritesFrom = null + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted || fixture.grants.writeGranted) + } + + @Test + fun `later background reconciliation finishes a committed removal after repeated failure`() { + val fixture = preparedCleanup() + fixture.grants.failRelease = true + fixture.lifecycle.finishPairCleanupOrRetry(PAIR_ID, allowDeferredCleanup = true) { state() } + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + fixture.grants.failRelease = false + fixture.lifecycle.reconcile(state(), reclaimUnrestoredReady = true) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted || fixture.grants.writeGranted) + } + + @Test + fun `opaque restoration claims the grant before the reclamation deadline`() = runBlocking { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + reconcileFileSyncCapabilitiesAfterRestoration(Mutex(), { state() }, fixture.lifecycle) { + val reference = dev.obiente.nextcloudnative.app.FileSyncLocalRoot(RECORD_ID, "Notes", RECORD_ID) + val restored = fixture.lifecycle.restoreSelection(ACCOUNT_ID, reference) + assertEquals(ROOT_URI, restored?.localRootId) + } + assertEquals(NEW_GENERATION, fixture.store.list().single().processGeneration) + assertTrue(fixture.grants.readGranted && fixture.grants.writeGranted) + } + + @Test + fun `ambiguous record removal accepts confirmed absence and remains idempotent`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.storage.failWriteNumber = fixture.storage.writes + 2 + fixture.storage.persistFailedWrite = true + assertTrue(fixture.lifecycle.abandonSelection(ROOT_URI)) + assertTrue(fixture.lifecycle.abandonSelection(ROOT_URI)) + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted || fixture.grants.writeGranted) + } + + @Test + fun `pre-existing exact grant is never taken or revoked`() { + val fixture = fixture(readGranted = true, writeGranted = true) + val root = fixture.lifecycle.acquire(ACCOUNT_ID, 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(ACCOUNT_ID, 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(ACCOUNT_ID, 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(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.grants.events.clear() + + assertFailsWith { + fixture.lifecycle.acquire(ACCOUNT_ID, 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 `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") + + 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(ACCOUNT_ID, 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(ACCOUNT_ID, 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(ACCOUNT_ID, 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(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) + 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 `failed setup abandonment remains retryable without restart`() { + val fixture = fixture() + val root = fixture.lifecycle.acquire(ACCOUNT_ID, 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 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) + 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 `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(ACCOUNT_ID, 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 `either recorded owner can reauthorize a legacy shared root`() { + val owners = listOf(pair(), pair(id = OTHER_PAIR_ID).copy(accountId = "other-account")) + for (owner in owners) for (previouslyAdopted in listOf(false, true)) { + val fixture = fixture(readGranted = previouslyAdopted, writeGranted = previouslyAdopted, loadConfiguredPairs = { owners }) + fixture.lifecycle.reconcile(state(*owners.toTypedArray())) + fixture.grants.readGranted = false + fixture.grants.writeGranted = false + val restored = fixture.lifecycle.acquire(AndroidFileSyncCapabilityAccountId(owner.accountId), ROOT_URI, "Notes") + assertTrue(restored.accessRestored) + assertEquals(setOf(PAIR_ID, OTHER_PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted && fixture.grants.writeGranted) + } + } + + @Test + fun `an unrelated or stale legacy owner cannot retake a shared root grant`() { + val owners = listOf(pair(), pair(id = OTHER_PAIR_ID).copy(accountId = "other-account")) + val fixture = fixture(readGranted = true, writeGranted = true, loadConfiguredPairs = { owners.filter { it.id == OTHER_PAIR_ID } }) + fixture.lifecycle.reconcile(state(*owners.toTypedArray())) + fixture.grants.readGranted = false + fixture.grants.writeGranted = false + assertFailsWith { fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") } + assertFailsWith { fixture.lifecycle.acquire(AndroidFileSyncCapabilityAccountId("unrelated"), ROOT_URI, "Notes") } + assertFalse(fixture.grants.readGranted || 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) + + assertFailsWith { + 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 `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) + 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(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, 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 `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() + fixture.lifecycle.acquire(ACCOUNT_ID, 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(ACCOUNT_ID, 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() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, 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 `uncommitted cleanup failure remains retryable through setup abandonment`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) + fixture.grants.failReleaseCount = 1 + assertFailsWith { + recoverFailedFileSyncPairSave(PAIR_ID, { state() }, fixture.lifecycle::abandonUncommittedPair) + } + assertTrue(fixture.store.list().single().pairIds.isEmpty()) + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + assertTrue(fixture.lifecycle.abandonSelection(ROOT_URI)) + 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(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, 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) + } + + @Test + fun `postcommit pair removal failure releases from the authoritative state immediately`() { + val fixture = fixture() + fixture.lifecycle.acquire(ACCOUNT_ID, ROOT_URI, "Notes") + fixture.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) + fixture.lifecycle.preparePairCleanup(PAIR_ID) + + fixture.lifecycle.persistPairRemoval(PAIR_ID, load = { state() }) { + error("save reported failure after commit") + } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @Test + fun `a failed provider does not starve an independent pending cleanup`() { + val fixture = preparedCleanup() + val first = fixture.store.list().single() + fixture.store.add(first.copy(id = java.util.UUID.randomUUID().toString(), uri = "$ROOT_URI-other", pairIds = emptySet())) + fixture.grants.failQueryUri = ROOT_URI + assertFailsWith { fixture.lifecycle.reconcile(state()) } + assertEquals(listOf(first.id), fixture.store.list().map { it.id }) + assertTrue(fixture.lifecycle.hasRecoveryWork()) + } + + @Test + fun `ambiguous save reports committed removal while grant cleanup remains pending`() { + val fixture = preparedCleanup() + fixture.grants.failRelease = true + fixture.lifecycle.persistPairRemoval(PAIR_ID, load = { state() }) { + error("save failed after commit") + } + assertEquals(AndroidFileSyncCapabilityPhase.CleanupPending, fixture.store.list().single().phase) + assertTrue(fixture.lifecycle.hasRecoveryWork()) + fixture.grants.failRelease = false + fixture.lifecycle.reconcile(state()) + assertTrue(fixture.store.list().isEmpty()) + } + + @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(ACCOUNT_ID, ROOT_URI, "Notes") + it.lifecycle.bindReady(ACCOUNT_ID, ROOT_URI, PAIR_ID) + it.lifecycle.preparePairCleanup(PAIR_ID) + } + + private fun fixture( + generation: String = NEW_GENERATION, + readGranted: Boolean = false, + writeGranted: Boolean = false, + requestRecovery: () -> Unit = {}, + loadConfiguredPairs: () -> List = { emptyList() }, + ): Fixture { + val storage = FakeStorage() + val store = AndroidFileSyncCapabilityStore(storage, IdentityCipher) + val grants = FakeGrantAccess(readGranted, writeGranted) + return Fixture(storage, store, grants, AndroidFileSyncCapabilityLifecycle(store, grants, generation, requestRecovery = requestRecovery, loadConfiguredPairs = loadConfiguredPairs)) + } + + 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, + accountId: AndroidFileSyncCapabilityAccountId? = ACCOUNT_ID, + ) { + store.add(record(generation, AndroidFileSyncCapabilityPhase.Ready, accountId = accountId)) + 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 + var persistOnlyWriteNumber: Int? = null + + override fun read(): String? = value + + override fun write(value: String): Boolean { + writes += 1 + if (writes == failWriteNumber || writes >= (failWritesFrom ?: Int.MAX_VALUE)) { + if (persistFailedWrite || writes == persistOnlyWriteNumber) this.value = value + return false + } + this.value = value + return true + } + } + + private class FakeGrantAccess( + var readGranted: Boolean, + var writeGranted: Boolean, + ) : AndroidFileSyncGrantAccess { + var failQueryUri: String? = null + 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 || uri == failQueryUri || failQueryCount > 0) { + failQueryCount = (failQueryCount - 1).coerceAtLeast(0) + 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 || failReleaseCount > 0) { + failReleaseCount = (failReleaseCount - 1).coerceAtLeast(0) + 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 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() + + 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 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() + val OLD_GENERATION: String = UUID.randomUUID().toString() + val NEW_GENERATION: String = UUID.randomUUID().toString() + + fun record( + generation: String, + phase: AndroidFileSyncCapabilityPhase, + pairIds: Set = emptySet(), + accountId: AndroidFileSyncCapabilityAccountId? = ACCOUNT_ID, + ) = AndroidFileSyncCapabilityRecord( + id = RECORD_ID, + uri = ROOT_URI, + displayName = "Notes", + phase = phase, + processGeneration = generation, + preExistingReadGrant = false, + preExistingWriteGrant = false, + accountId = accountId, + pairIds = pairIds, + ) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index dcaccc5d1..ab7d469eb 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( @@ -571,14 +568,19 @@ 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", - "release-$retiredRoot", "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,19 +620,22 @@ 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" }, ) } - 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 @@ -641,19 +646,19 @@ 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" }, ) } - assertEquals(listOf("cancel-schedule", "cancel-notification"), events) + assertEquals(listOf("prepare-grant", "cancel-schedule", "cancel-notification"), events) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairSaveCancellationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairSaveCancellationTest.kt new file mode 100644 index 000000000..3c3f5bcef --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairSaveCancellationTest.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState +import dev.obiente.nextcloudnative.app.FileSyncPair +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class AndroidFileSyncPairSaveCancellationTest { + @Test + fun cancellationAfterDurableSaveRetainsOwnershipWithoutContinuingToSchedule() { + val cancelled = CancellationException("synthetic save cancellation") + var persisted = AndroidFileSyncPersistedState() + var abandoned = false + var scheduled = false + val failure = assertFailsWith { + bindAndPersistFileSyncPair( + pair.id, bindReady = {}, + persist = { + persisted = AndroidFileSyncPersistedState(coordinator = FileSyncCoordinatorState(listOf(pair))) + throw cancelled + }, + load = { persisted }, abandonUncommittedPair = { abandoned = true; true }, + ) + scheduled = true + } + assertSame(cancelled, failure) + assertEquals(listOf(pair), persisted.coordinator.pairs) + assertFalse(abandoned) + assertFalse(scheduled) + } + + @Test + fun cancellationBeforeSaveReleasesOnlyAuthoritativelyUncommittedOwnership() { + val cancelled = CancellationException("synthetic precommit cancellation") + var abandoned = false + val failure = assertFailsWith { + bindAndPersistFileSyncPair( + pair.id, bindReady = {}, persist = { throw cancelled }, + load = { AndroidFileSyncPersistedState() }, + abandonUncommittedPair = { abandoned = true; true }, + ) + } + assertSame(cancelled, failure) + assertTrue(abandoned) + } + + @Test + fun cancellationDuringAuthoritativeReloadNeverAbandonsUnknownOwnership() { + for (initialCancelled in listOf(false, true)) { + val initial = if (initialCancelled) CancellationException("save cancelled") else IllegalStateException("save failed") + val reload = CancellationException("reload cancelled") + var abandoned = false + val failure = assertFailsWith { + bindAndPersistFileSyncPair( + pair.id, bindReady = {}, persist = { throw initial }, load = { throw reload }, + abandonUncommittedPair = { abandoned = true; true }, + ) + } + assertSame(if (initialCancelled) initial else reload, failure) + assertFalse(abandoned) + } + } + + private val pair = FileSyncPair( + id = "00000000-0000-0000-0000-000000000001", accountId = "synthetic-account", + localRootId = "content://example.documents/tree/notes", remoteRootPath = "Notes", + configuration = FileSyncConfiguration(deviceLabel = "Test device"), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisitionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisitionTest.kt new file mode 100644 index 000000000..35701b647 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootAcquisitionTest.kt @@ -0,0 +1,122 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout + +class AndroidFileSyncRootAcquisitionTest { + private val root = FileSyncLocalRoot("content://example.documents/tree/folder", "Folder", "opaque-id") + + @Test + fun acquisitionUsesIoAndResultDeliveryUsesMain() = runBlocking { + Executors.newSingleThreadExecutor { Thread(it, "synthetic-io") }.asCoroutineDispatcher().use { io -> + Executors.newSingleThreadExecutor { Thread(it, "synthetic-main") }.asCoroutineDispatcher().use { main -> + val owner = CoroutineScope(SupervisorJob() + io) + val ready = CompletableDeferred>() + val consumer = async(main) { + val result = suspendCancellableCoroutine { ready.complete(it) } + result to Thread.currentThread().name.substringBefore(" @coroutine#") + } + var acquiredOn = "" + try { + acquireFileSyncRootForDelivery(owner, ready.await(), { + acquiredOn = Thread.currentThread().name.substringBefore(" @coroutine#") + root + }, { error("Delivered root must remain owned") }, main).join() + val result = consumer.await() + assertEquals(root, result.first) + assertEquals("synthetic-main", result.second) + assertEquals("synthetic-io", acquiredOn) + } finally { + owner.cancel() + } + } + } + } + + @Test + fun cancellationBeforeMainDeliveryReclaimsOnIo() = runBlocking { + Executors.newSingleThreadExecutor { Thread(it, "synthetic-cleanup-io") }.asCoroutineDispatcher().use { io -> + val owner = CoroutineScope(SupervisorJob() + io) + val main = PausedDispatcher() + lateinit var continuation: CancellableContinuation + val consumer = async(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation = it } + } + val cleaned = CompletableDeferred>() + try { + val acquisition = acquireFileSyncRootForDelivery(owner, continuation, { root }, { + cleaned.complete(it to Thread.currentThread().name.substringBefore(" @coroutine#")) + }, main) + withTimeout(15_000) { main.awaitTask() } + consumer.cancel() + main.runAll() + acquisition.join() + assertEquals(root.savedStateId to "synthetic-cleanup-io", withTimeout(15_000) { cleaned.await() }) + } finally { + owner.cancel() + } + } + } + + @Test + fun ownerCancellationAfterAcquisitionStillReclaimsTheGrant() = runBlocking { + Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { io -> + val owner = CoroutineScope(SupervisorJob() + io) + val main = PausedDispatcher() + lateinit var continuation: CancellableContinuation + val consumer = async(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation = it } + } + val cleaned = CompletableDeferred() + val acquisition = acquireFileSyncRootForDelivery(owner, continuation, { root }, { + cleaned.complete(it) + }, main) + withTimeout(15_000) { main.awaitTask() } + owner.cancel() + main.runAll() + acquisition.join() + assertEquals(root.savedStateId, withTimeout(15_000) { cleaned.await() }) + assertTrue(consumer.isCancelled) + } + } + + @Test + fun cancelledRequestDoesNotAcquireAnyGrant() = runBlocking { + val owner = CoroutineScope(coroutineContext + SupervisorJob()) + lateinit var continuation: CancellableContinuation + val consumer = async(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation = it } + } + consumer.cancel() + var acquired = false + acquireFileSyncRootForDelivery(owner, continuation, { acquired = true; root }, {}, PausedDispatcher()).join() + assertFalse(acquired) + owner.cancel() + } + + private class PausedDispatcher : CoroutineDispatcher() { + private val tasks = ConcurrentLinkedQueue() + private val queued = CompletableDeferred() + override fun dispatch(context: CoroutineContext, block: Runnable) { tasks.add(block); queued.complete(Unit) } + suspend fun awaitTask() = queued.await() + fun runAll() { while (true) (tasks.poll() ?: return).run() } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index 00ceed34a..761371741 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -14,6 +14,7 @@ import dev.obiente.nextcloudnative.app.newFileSyncUploadCheckpoint import dev.obiente.nextcloudnative.app.nextcloudUploadTransferPlan import dev.obiente.nextcloudnative.app.scanFileSyncPair import java.io.File +import java.nio.file.AccessDeniedException import java.nio.file.Files import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors @@ -79,7 +80,7 @@ class AndroidFileSyncStoreTest { val writes = executor.submit { start.await() repeat(100) { index -> - writer.save(if (index % 2 == 0) second else first) + saveWhileReadersHoldWindowsHandles { writer.save(if (index % 2 == 0) second else first) } } } val reads = executor.submit { @@ -155,6 +156,7 @@ class AndroidFileSyncStoreTest { } assertTrue(!stateFile.exists()) + AndroidFileSyncStore(stateFile).loadAndReconcileUploadCleanups() assertEquals( listOf(cleanup), AndroidFileSyncUploadCleanupStore(File(directory, "state.bin.upload-cleanups")) @@ -203,6 +205,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 +335,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 +348,26 @@ 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" + } +} + +// Windows may reject replacement while another reader holds the destination open. +// Every write must still complete; readers continue asserting whole snapshots. +private fun saveWhileReadersHoldWindowsHandles(save: () -> Unit) { + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (true) { + try { + save() + return + } catch (failure: AccessDeniedException) { + if (!System.getProperty("os.name").startsWith("Windows") || System.nanoTime() >= deadline) { + throw failure + } + Thread.sleep(10) + } + } } diff --git a/changes/unreleased/445-durable-folder-access-cleanup.md b/changes/unreleased/445-durable-folder-access-cleanup.md new file mode 100644 index 000000000..c89c3cd31 --- /dev/null +++ b/changes/unreleased/445-durable-folder-access-cleanup.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 445 +platforms: android +user-facing: yes + +Retry interrupted folder access cleanup in the background and release restored folder selections when setup is dismissed or replaced. diff --git a/changes/unreleased/445-folder-capability-recovery.md b/changes/unreleased/445-folder-capability-recovery.md new file mode 100644 index 000000000..da37047b4 --- /dev/null +++ b/changes/unreleased/445-folder-capability-recovery.md @@ -0,0 +1,7 @@ +category: security +issue: none +pull: 445 +platforms: android, desktop +user-facing: yes + +Preserve folder cleanup through interrupted setup, restore saved access through opaque references, and let Android users renew expired permissions for existing sync pairs. Release abandoned setup grants after a bounded restoration window. diff --git a/changes/unreleased/445-folder-recovery-review.md b/changes/unreleased/445-folder-recovery-review.md new file mode 100644 index 000000000..3189ca236 --- /dev/null +++ b/changes/unreleased/445-folder-recovery-review.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 445 +platforms: android +user-facing: yes + +Restore folder drafts and legacy access, reclaim abandoned grants without blocking the UI, and continue independent cleanup after provider failures. Committed removals report success while remaining permission cleanup retries. diff --git a/changes/unreleased/445-recovery-completion-cancellation.md b/changes/unreleased/445-recovery-completion-cancellation.md new file mode 100644 index 000000000..62a15bc86 --- /dev/null +++ b/changes/unreleased/445-recovery-completion-cancellation.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 445 +platforms: android +user-facing: yes + +Finish completed folder-access recovery without an idle wait, and preserve cancellation after a folder pair was durably saved. 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..c6ae7d000 --- /dev/null +++ b/changes/unreleased/android-file-sync-capability-lifecycle.md @@ -0,0 +1,7 @@ +category: fix +issue: 11 +pull: 445 +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/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 015149f37..056fb9d06 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,4 +1,4 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|850 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 @@ -11,7 +11,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|919 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1809 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2512 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2600 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2598 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|973 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt|567 @@ -28,7 +28,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1717 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index d39deeed3..cad614c8b 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,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()) } - var pendingLocalRootJson by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(null) - } - var pendingMediaSuggestionJson by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(null) - } - var pendingRemotePath by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(null) - } - var pendingSyncConfigurationJson by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(null) - } - var remoteFolderPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(false) - } - var syncSelectionPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { - mutableStateOf(false) - } - val pendingLocalRoot = pendingLocalRootJson?.let { encoded -> - runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() + 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() } @@ -151,7 +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(): Boolean { + val abandoned = setupDraft.abandon(services::abandonFileSyncLocalRoot) + if (!abandoned) { + actionMessage = "Could not release the selected folder. Choose Add folder to retry cleanup." + } + pendingMediaPreview = null + return abandoned + } + AbandonFileSyncRootOnDispose(services, setupDraft.localRoot) fun runItemAction(item: FileOfflineCenterItem, remove: Boolean) { if (actionKey != null) return actionKey = item.key @@ -172,7 +173,6 @@ internal fun FileOfflineCenterScreen( actionKey = null } } - fun runSyncAction(pairId: String, remove: Boolean) { if (pairId in syncBusyPairIds) return syncBusyPairIds += pairId @@ -193,16 +193,21 @@ internal fun FileOfflineCenterScreen( syncBusyPairIds -= pairId } } - fun beginAddFolderSync() { if (ADD_PAIR_BUSY_ID in syncBusyPairIds) return + if (pendingLocalRoot != null && !abandonPendingFolderSync()) return syncBusyPairIds += ADD_PAIR_BUSY_ID scope.launch { try { - runCatching { services.chooseFileSyncLocalRoot() } + runCatching { services.chooseFileSyncLocalRoot(session) } .onSuccess { selected -> + if (selected?.accessRestored == true) { + actionMessage = "Folder access restored. Resume the existing sync pair." + refreshAttempt += 1 + return@onSuccess + } pendingMediaSuggestionJson = null - pendingLocalRootJson = selected?.let { fileSyncSetupJson.encodeToString(it) } + pendingLocalRoot = selected pendingRemotePath = selected?.let { "" } pendingSyncConfigurationJson = selected ?.let { defaultFileSyncConfiguration(isMediaSuggestion = false) } @@ -218,12 +223,11 @@ internal fun FileOfflineCenterScreen( } } } - fun openMediaSuggestion(suggestion: MediaSyncFolderSuggestion) { 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), @@ -231,7 +235,6 @@ internal fun FileOfflineCenterScreen( remoteFolderPickerVisible = false syncSelectionPickerVisible = false } - fun resolveSyncConflict(target: PendingFileSyncDecision) { if (target.pair.id in syncBusyPairIds) return syncBusyPairIds += target.pair.id @@ -255,7 +258,6 @@ internal fun FileOfflineCenterScreen( syncBusyPairIds -= target.pair.id } } - fun saveVirtualStoragePolicy(policy: VirtualFileCachePolicy) { if (virtualStorageBusy) return virtualStorageBusy = true @@ -470,6 +472,10 @@ internal fun FileOfflineCenterScreen( if (userId.isBlank() || !services.supportsBidirectionalFileSync) return@LaunchedEffect syncLoading = true try { + if (!restoreAndReconcileFileSyncRootSetup(services, session, setupDraft)) { + setupDraft.clear() + actionMessage = "Select the local folder again to restore folder access." + } syncSnapshot = services.loadFileSyncCenter(session, userId) } catch (cancelled: CancellationException) { throw cancelled @@ -1005,9 +1011,7 @@ internal fun FileOfflineCenterScreen( onDismiss = { remoteFolderPickerVisible = false if (pendingRemotePath == null) { - pendingLocalRootJson = null - pendingMediaSuggestionJson = null - pendingSyncConfigurationJson = null + abandonPendingFolderSync() } }, onSelected = { selectedPath -> @@ -1064,12 +1068,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,16 +1096,15 @@ internal fun FileOfflineCenterScreen( }.onSuccess { result -> actionMessage = result.fileSyncCenterMessage() if (result is FileSyncCenterActionResult.Completed) { - pendingLocalRootJson = null - pendingMediaSuggestionJson = null - pendingRemotePath = null - pendingSyncConfigurationJson = null + setupDraft.clear() pendingMediaPreview = null - syncSelectionPickerVisible = false refreshAttempt += 1 + } else { + abandonPendingFolderSync() } }.onFailure { failure -> 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/FileSyncCenter.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt index b7a8ea712..5da408e75 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt @@ -12,10 +12,14 @@ import kotlinx.serialization.Serializable data class FileSyncLocalRoot( val localRootId: String, val displayName: String, + /** Opaque, non-secret platform reference suitable for saved UI state. Never a provider URI. */ + val savedStateId: String? = null, + val accessRestored: Boolean = false, ) { init { require(localRootId.isSafeFileSyncCenterText(2_048)) require(displayName.isSafeFileSyncCenterText(256)) + require(savedStateId == null || savedStateId.isSafeFileSyncCenterText(256)) } } 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..9d4604402 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt @@ -0,0 +1,157 @@ +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], savedStateId = saved[2]) }.getOrNull() ?: return null + "2" -> saved[2].takeIf { it.startsWith("media-store://primary/") } + ?.let { runCatching { FileSyncLocalRoot(it, 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?.takeIf { it.savedStateId != null || it.localRootId.startsWith("media-store://primary/") } + val rootKind = when { + root == null -> "0" + root.savedStateId != null -> "1" + else -> "2" + } + val rootReference = root?.let { it.savedStateId ?: it.localRootId }.orEmpty() + val remote = remotePath.value + val saved = listOf( + SAVED_SETUP_VERSION, + rootKind, + rootReference, + 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, + rootKind, + rootReference, + root?.displayName.orEmpty(), + "", + "0", + "", + "", + "false", + "false", + ) +} + +internal val FileSyncSetupDraftSaver = Saver>( + save = { draft -> draft.savedState() }, + restore = { saved -> FileSyncSetupDraftState.restore(saved) }, +) + +@Composable +internal fun AbandonFileSyncRootOnDispose( + services: NextcloudPlatformServices, + localRoot: State, +) { + DisposableEffect(services, localRoot) { + onDispose(fileSyncRootDisposal( + currentRoot = { localRoot.value }, + abandon = services::abandonFileSyncLocalRoot, + retainRoot = services::retainFileSyncRootOnDispose, + )) + } +} + +internal fun fileSyncRootDisposal( + currentRoot: () -> FileSyncLocalRoot?, + abandon: (FileSyncLocalRoot) -> Boolean, + retainRoot: () -> Boolean = { false }, +): () -> Unit = { if (!retainRoot()) currentRoot()?.let(abandon) } + +internal 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-v2" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestoration.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestoration.kt new file mode 100644 index 000000000..f60577121 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestoration.kt @@ -0,0 +1,36 @@ +package dev.obiente.nextcloudnative.app + +internal suspend fun restoreAndReconcileFileSyncRootSetup( + services: NextcloudPlatformServices, + session: NextcloudSession, + draft: FileSyncSetupDraftState, +): Boolean = restoreAndReconcileFileSyncRootSetup( + draft, + restore = { services.restoreFileSyncLocalRoot(session, it) }, + reconcile = { services.reconcileFileSyncRootSetup(session, it) }, + abandon = services::abandonFileSyncLocalRoot, +) + +internal suspend fun restoreAndReconcileFileSyncRootSetup( + draft: FileSyncSetupDraftState, + restore: suspend (FileSyncLocalRoot) -> FileSyncLocalRoot?, + reconcile: suspend (FileSyncLocalRoot?) -> Boolean, + abandon: (FileSyncLocalRoot) -> Boolean, +): Boolean { + val previous = draft.localRoot.value + val restored = previous?.let { restore(it) } + var retainedByDraft = false + try { + if (draft.localRoot.value !== previous) return true + if (previous != null && restored == null) return false + val reconciled = reconcile(restored) + if (draft.localRoot.value !== previous) return true + draft.localRoot.value = restored + retainedByDraft = true + return reconciled + } finally { + if (!retainedByDraft && restored != null && draft.localRoot.value !== restored) { + tryAbandonFileSyncRoot(restored, 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..ffb5eea23 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -932,17 +932,17 @@ 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 restoreFileSyncLocalRoot(session: NextcloudSession, reference: FileSyncLocalRoot): FileSyncLocalRoot? = reference + 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, 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 new file mode 100644 index 000000000..49bf0d67c --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt @@ -0,0 +1,129 @@ +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 + 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", savedStateId = "opaque-record-id") + + pendingRoot = deliveredRoot + dispose() + + assertEquals(listOf(deliveredRoot), abandoned) + } + + @Test + fun `activity recreation retains the delivered root for restored setup`() { + val deliveredRoot = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes", savedStateId = "opaque-record-id") + 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", savedStateId = "opaque-record-id") + 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("opaque-record-id", restored.localRoot.value?.localRootId) + assertFalse(assertNotNull(draft.savedState()).any { it.contains("content://") }) + 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", savedStateId = "opaque-record-id") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + configurationJson.value = "x".repeat(32 * 1024) + } + + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + + assertEquals("opaque-record-id", restored.localRoot.value?.localRootId) + assertNull(restored.configurationJson.value) + } + + @Test + fun `capability without an opaque saved reference is never serialized`() { + val draft = FileSyncSetupDraftState().apply { + localRoot.value = FileSyncLocalRoot("content://example.documents/tree/private", "Folder") + } + val saved = assertNotNull(draft.savedState()) + assertFalse(saved.any { it.contains("content://") }) + assertNull(assertNotNull(FileSyncSetupDraftState.restore(saved)).localRoot.value) + } + + @Test + fun `detected media setup survives recreation without a SAF capability`() { + val root = FileSyncLocalRoot("media-store://primary/DCIM/Camera", "Camera") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + mediaSuggestionJson.value = "synthetic suggestion" + configurationJson.value = "synthetic configuration" + } + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + assertEquals(root, restored.localRoot.value) + assertEquals(draft.configurationJson.value, restored.configurationJson.value) + } + + @Test + fun `media setup discriminator rejects provider capability references`() { + val draft = FileSyncSetupDraftState().apply { + localRoot.value = FileSyncLocalRoot("media-store://primary/DCIM/Camera", "Camera") + } + val saved = assertNotNull(draft.savedState()).toMutableList() + saved[2] = "content://example.documents/tree/private" + assertNull(FileSyncSetupDraftState.restore(saved)) + } + + @Test + fun `failed abandonment keeps the root available for retry`() { + val root = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes", savedStateId = "opaque-record-id") + 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) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestorationTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestorationTest.kt new file mode 100644 index 000000000..26e0df430 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestorationTest.kt @@ -0,0 +1,62 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FileSyncSetupRestorationTest { + private val reference = FileSyncLocalRoot("opaque-id", "Notes", "opaque-id") + private val restored = FileSyncLocalRoot("content://test/tree/notes", "Notes", "opaque-id") + + @Test + fun dismissalDuringRestoreReleasesTheResolvedCapability() = runBlocking { + val draft = FileSyncSetupDraftState().apply { localRoot.value = reference } + val abandoned = mutableListOf() + assertTrue(restoreAndReconcileFileSyncRootSetup(draft, + restore = { draft.clear(); restored }, + reconcile = { error("Stale restoration cannot reconcile") }, + abandon = { abandoned += it; true }, + )) + assertEquals(listOf(restored), abandoned) + assertNull(draft.localRoot.value) + } + + @Test + fun replacementDuringReconciliationRetainsTheNewDraftAndReleasesTheOldRoot() = runBlocking { + val draft = FileSyncSetupDraftState().apply { localRoot.value = reference } + val replacement = FileSyncLocalRoot("another-root", "Other") + val abandoned = mutableListOf() + restoreAndReconcileFileSyncRootSetup(draft, restore = { restored }, + reconcile = { draft.localRoot.value = replacement; true }, + abandon = { abandoned += it; true }, + ) + assertEquals(replacement, draft.localRoot.value) + assertEquals(listOf(restored), abandoned) + } + + @Test + fun failedReconciliationKeepsTheResolvedRootAvailableForAbandonment() = runBlocking { + val draft = FileSyncSetupDraftState().apply { localRoot.value = reference } + kotlin.test.assertFalse(restoreAndReconcileFileSyncRootSetup(draft, + restore = { restored }, reconcile = { false }, abandon = { error("Still owned by draft") }, + )) + assertEquals(restored, draft.localRoot.value) + } + + @Test + fun cancelledReconciliationReleasesTheRestoredRoot() = runBlocking { + val draft = FileSyncSetupDraftState().apply { localRoot.value = reference } + val abandoned = mutableListOf() + assertFailsWith { + restoreAndReconcileFileSyncRootSetup(draft, restore = { restored }, + reconcile = { throw CancellationException() }, + abandon = { abandoned += it; true }, + ) + } + assertEquals(listOf(restored), abandoned) + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt index 84c2dd5d2..f6db85e9e 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt @@ -31,7 +31,7 @@ internal class DesktopFileSyncEngine( } val token = "desktop-selection:${UUID.randomUUID()}" selectedRoots[token] = selected.toFile() - FileSyncLocalRoot(token, selected.fileName?.toString()?.takeIf(String::isNotBlank) ?: "Selected folder") + FileSyncLocalRoot(token, selected.fileName?.toString()?.takeIf(String::isNotBlank) ?: "Selected folder", savedStateId = token) } suspend fun loadCenter( 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 3d4841d71..914770191 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( 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..1b1a7be7e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt @@ -340,7 +340,12 @@ 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) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 097196c6a..5276875a1 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -117,7 +117,9 @@ "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/FileSyncSetupRestoration.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt", @@ -518,7 +520,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": "e1394f6e92bdfaff20cb6490bf9cf5568752f4a94aadd918e5273b551aa23659", "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", @@ -527,7 +529,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareRecipientPicker.kt": "ab42deb76c7515f2d2056118b552a36c87dd8a13a32bcffc6d0c8750f657e0f4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSharing.kt": "b1aa1b070e011f1c935db2666682171a3d1fd51e324b192feb64d2edee1910d9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncActionDiagnostics.kt": "961e9af0bd166838dca36e56ae573a37cde4976139197d95d62e73c3cd8c926a", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt": "1489533012f6e7e632231bed0df0853a81ac6777f4c38564732be8f62755f2c4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt": "25c8fc7ed26be6bcf1090bad268dcb04dfec575849bf55f6ff6ed0811ceed26e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncClaiming.kt": "7b69ad4896987318ca025295a2fc1023a2ed5afbba9fe188a14f92b7b37b4a1f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncConflictResolution.kt": "6af8d6d8e9eb4f2423d9f1074869ea2322c36dbc52a9149ce6455a0805c66400", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncConflictReview.kt": "edbf3f76cbb1f77885249531beac6b56c40da27bfbb541a8304fa253f65267d4", @@ -541,7 +543,9 @@ "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": "f8b3fb6c90fed8b86fa9c286b9dc6448ab48f41516675ac60b1e74afb47e918f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt": "33242928be5d216ad664c742212994b1074d7c5d8947f18906c1feb78d922d1b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestoration.kt": "78defba5abc4882b1557b3a566b45354dac7089049a0059f6b8670c82b26f2f1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt": "de8740ee24e98477b7ac3fac51001f7fb8d86a5a2b589905724a621e32ddc7b9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt": "7d01527acc00eafe092885d19f318734263597b4c2c5c09cf39159d174a371dd", @@ -648,7 +652,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": "5370183fb78190a6893e4a36638b0570de4d2d6b33acab18aef52f2b83d83589", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "f40d1f7b38155fe933b6a7cd6e5cb7e7996d7add5f433eb8fd75e8aa68b8c8d6", "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": "93550b3618776a8ec41bc50a724abe0cb2ad8adcd9f83f1375de7ea7d62a9c81",