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..b3a461773 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycle.kt @@ -0,0 +1,601 @@ +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. No folder permissions were changed.", + cause, +) + +internal interface AndroidFileSyncCapabilityEncryptedStorage { + fun read(): String? + fun write(value: String): Boolean +} + +internal interface AndroidFileSyncCapabilityCipher { + fun encrypt(value: String): String + fun decrypt(value: String): String +} + +internal interface AndroidFileSyncGrantAccess { + fun exactGrant(uri: String): AndroidFileSyncGrantState + fun takeExactReadWriteGrant(uri: String) + fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) +} + +internal data class AndroidFileSyncGrantState(val read: Boolean, val write: Boolean) + +@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) { + throw AndroidFileSyncCapabilityRecoveryException(failure) + } ?: return emptyList() + return try { + val array = JSONArray(cipher.decrypt(encrypted)) + check(array.length() <= MAX_CAPABILITY_RECORDS) { "Too many folder capabilities were saved." } + buildList { + repeat(array.length()) { index -> add(array.getJSONObject(index).toCapabilityRecord()) } + }.also { records -> + check(records.distinctBy(AndroidFileSyncCapabilityRecord::id).size == records.size) { + "Saved folder capability IDs are duplicated." + } + check(records.distinctBy(AndroidFileSyncCapabilityRecord::uri).size == records.size) { + "Saved folder capabilities are ambiguous." + } + } + } catch (failure: Exception) { + if (failure is AndroidFileSyncCapabilityRecoveryException) throw failure + throw AndroidFileSyncCapabilityRecoveryException(failure) + } + } + + private fun writeAll(records: List) { + val array = JSONArray() + records.forEach { array.put(it.toJson()) } + val encrypted = try { + cipher.encrypt(array.toString()) + } catch (failure: Exception) { + throw IllegalStateException("Folder capability recovery data could not be encrypted.", failure) + } + val saved = try { + storage.write(encrypted) + } catch (failure: Exception) { + throw IllegalStateException("Folder capability recovery data could not be saved.", failure) + } + check(saved) { "Folder capability recovery data could not be saved." } + } + + private companion object { + val LOCK = Any() + } +} + +internal class AndroidFileSyncCapabilityLifecycle internal constructor( + private val store: AndroidFileSyncCapabilityStore, + private val grants: AndroidFileSyncGrantAccess, + private val processGeneration: String, +) { + constructor(context: Context) : this( + AndroidFileSyncCapabilityStore(context.applicationContext), + ContentResolverFileSyncGrantAccess(context.applicationContext.contentResolver), + PROCESS_GENERATION, + ) + + fun acquire( + accountId: AndroidFileSyncCapabilityAccountId, + exactUri: String, + displayName: String, + ): FileSyncLocalRoot = synchronized(LIFECYCLE_LOCK) { + val preExisting = grants.exactGrant(exactUri) + val record = AndroidFileSyncCapabilityRecord( + id = UUID.randomUUID().toString(), + uri = exactUri, + displayName = displayName, + phase = AndroidFileSyncCapabilityPhase.Acquiring, + processGeneration = processGeneration, + preExistingReadGrant = preExisting.read, + preExistingWriteGrant = preExisting.write, + accountId = accountId, + ) + try { + store.add(record) + if (!preExisting.read || !preExisting.write) grants.takeExactReadWriteGrant(exactUri) + val acquired = grants.exactGrant(exactUri) + check(acquired.read && acquired.write) { + "The selected folder provider did not persist read and write access." + } + store.replace(record.id, AndroidFileSyncCapabilityPhase.Acquiring) { + it.copy(phase = AndroidFileSyncCapabilityPhase.Ready) + } + FileSyncLocalRoot(exactUri, displayName) + } catch (failure: Exception) { + recoverAcquisition(record.id) + throw failure + } + } + + fun bindReady( + 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 abandonSelection(localRootId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + it.uri == localRootId && + it.pairIds.isEmpty() && + it.phase in setOf( + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.CleanupPending, + ) + } ?: return@synchronized false + prepareAndFinishCleanup(record) + } + + fun abandonUncommittedPair(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.Owned + } ?: return@synchronized false + prepareAndFinishCleanup(record) + } + + fun preparePairCleanup(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { pairId in it.pairIds } + ?: return@synchronized false + when (record.phase) { + AndroidFileSyncCapabilityPhase.Owned -> { + store.replace(record.id, AndroidFileSyncCapabilityPhase.Owned) { + if (it.pairIds.size == 1) { + it.copy(phase = AndroidFileSyncCapabilityPhase.CleanupPending) + } else { + it.copy(pairIds = it.pairIds - pairId) + } + } + } + AndroidFileSyncCapabilityPhase.CleanupPending -> Unit + else -> error("The sync pair does not own its saved folder capability.") + } + true + } + + fun finishPairCleanup(pairId: String): Boolean = synchronized(LIFECYCLE_LOCK) { + val record = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.CleanupPending + } ?: return@synchronized false + finishCleanup(record) + } + + fun finishPairCleanupOrRetry( + pairId: String, + load: () -> AndroidFileSyncPersistedState, + ) = synchronized(LIFECYCLE_LOCK) { + val pending = store.list().singleOrNull { + pairId in it.pairIds && it.phase == AndroidFileSyncCapabilityPhase.CleanupPending + } ?: return@synchronized + if (finishCleanup(pending)) return@synchronized + reconcile(load()) + } + + fun persistPairRemoval( + load: () -> AndroidFileSyncPersistedState, + persist: () -> Unit, + ) = try { + persist() + } catch (failure: Exception) { + recoverAmbiguousPairRemoval(load) + throw failure + } + + private fun recoverAmbiguousPairRemoval(load: () -> AndroidFileSyncPersistedState) = synchronized(LIFECYCLE_LOCK) { + val authoritative = try { + load() + } catch (_: Exception) { + return@synchronized + } + runCatching { reconcile(authoritative) } + } + + fun reconcile(state: AndroidFileSyncPersistedState) = synchronized(LIFECYCLE_LOCK) { + var records = store.list() + val safPairs = state.coordinator.pairs.filter { it.localRootId.startsWith("content://") } + if (hasConflictingOwnership(records, safPairs)) return@synchronized + safPairs.groupBy(FileSyncPair::localRootId).forEach { (uri, matches) -> + if (records.none { it.uri == uri }) adoptLegacyCapability(uri, matches, state.localDisplayNames) + } + records = store.list() + records.forEach { original -> + val record = store.list().firstOrNull { it.id == original.id } ?: return@forEach + val matchingPairs = safPairs.filter { it.localRootId == record.uri } + val matchingIds = matchingPairs.mapTo(linkedSetOf(), FileSyncPair::id) + when (record.phase) { + AndroidFileSyncCapabilityPhase.Acquiring -> if (record.processGeneration != processGeneration) { + 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) { + if (matchingIds.isNotEmpty()) { + store.replace(record.id, record.phase) { + it.copy( + phase = AndroidFileSyncCapabilityPhase.Owned, + accountId = matchingPairs.singleAccountOwner(), + pairIds = matchingIds, + ) + } + } else if (record.accountId == null) { + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + } + AndroidFileSyncCapabilityPhase.Owned -> { + if (matchingIds.isNotEmpty() && matchingIds != record.pairIds) { + store.replace(record.id, record.phase) { it.copy(pairIds = matchingIds) } + } 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 } + } + } + } + } + } + + fun reconcileRestoredSetup( + accountId: AndroidFileSyncCapabilityAccountId, + restoredLocalRootId: String?, + state: AndroidFileSyncPersistedState, + ): Boolean = reconcileSetup(accountId, restoredLocalRootId, state, includeCurrentGeneration = false) + + fun retireAccountSetup( + accountId: AndroidFileSyncCapabilityAccountId, + state: AndroidFileSyncPersistedState, + ) = reconcileSetup(accountId, restoredLocalRootId = null, state, includeCurrentGeneration = true) + + private fun reconcileSetup( + accountId: AndroidFileSyncCapabilityAccountId, + restoredLocalRootId: String?, + state: AndroidFileSyncPersistedState, + includeCurrentGeneration: Boolean, + ): Boolean = synchronized(LIFECYCLE_LOCK) { + reconcile(state) + val restoredContentRoot = restoredLocalRootId?.takeIf { it.startsWith("content://") } + val records = store.list() + val restored = restoredContentRoot?.let { uri -> + records.singleOrNull { record -> + record.uri == uri && + record.accountId == accountId && + record.phase == AndroidFileSyncCapabilityPhase.Ready + } + } + if (restored != null && restored.processGeneration != processGeneration) { + store.replace(restored.id, AndroidFileSyncCapabilityPhase.Ready) { + it.copy(processGeneration = processGeneration) + } + } + records.asSequence() + .filter { record -> + record.accountId == accountId && + record.phase == AndroidFileSyncCapabilityPhase.Ready && + (includeCurrentGeneration || record.processGeneration != processGeneration) && + record.id != restored?.id + } + .forEach { record -> + check(prepareAndFinishCleanup(record)) { CLEANUP_RETRY_MESSAGE } + } + restoredContentRoot == null || restored != null + } + + private fun hasConflictingOwnership( + records: List, + pairs: List, + ): Boolean = records.any { record -> + record.pairIds.any { pairId -> + pairs.any { 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 (_: Exception) { + null + } ?: return + runCatching { prepareAndFinishCleanup(record) } + } + + private fun prepareAndFinishCleanup(record: AndroidFileSyncCapabilityRecord): Boolean { + val pending = when (record.phase) { + AndroidFileSyncCapabilityPhase.CleanupPending -> record + AndroidFileSyncCapabilityPhase.Acquiring, + AndroidFileSyncCapabilityPhase.Ready, + AndroidFileSyncCapabilityPhase.Owned, + -> store.replace(record.id, record.phase) { + it.copy(phase = AndroidFileSyncCapabilityPhase.CleanupPending) + } + } + return finishCleanup(pending) + } + + private fun finishCleanup(record: AndroidFileSyncCapabilityRecord): Boolean { + val ownedRead = !record.preExistingReadGrant + val ownedWrite = !record.preExistingWriteGrant + if (ownedRead || ownedWrite) { + val granted = try { + grants.exactGrant(record.uri) + } catch (_: Exception) { + return false + } + if ((ownedRead && granted.read) || (ownedWrite && granted.write)) { + try { + grants.releaseExactGrant(record.uri, ownedRead, ownedWrite) + } catch (_: Exception) { + return false + } + val retained = try { + grants.exactGrant(record.uri) + } catch (_: Exception) { + return false + } + if ((ownedRead && retained.read) || (ownedWrite && retained.write)) return false + if ((record.preExistingReadGrant && !retained.read) || + (record.preExistingWriteGrant && !retained.write) + ) return false + } + } + return try { + store.remove(record.id, AndroidFileSyncCapabilityPhase.CleanupPending) + true + } catch (_: Exception) { + false + } + } + + private companion object { + val LIFECYCLE_LOCK = Any() + val PROCESS_GENERATION: String = UUID.randomUUID().toString() + } +} + +private class SharedPreferencesFileSyncCapabilityStorage(context: Context) : + AndroidFileSyncCapabilityEncryptedStorage { + private val preferences = context.applicationContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + + override fun read(): String? = preferences.getString(KEY_RECORDS, null) + + override fun write(value: String): Boolean = preferences.edit().putString(KEY_RECORDS, value).commit() + + private companion object { + const val PREFERENCES = "nextcloud_native_file_sync_capabilities" + const val KEY_RECORDS = "records" + } +} + +private class SessionFileSyncCapabilityCipher : AndroidFileSyncCapabilityCipher { + private val delegate = SessionCipher() + + override fun encrypt(value: String): String = delegate.encrypt(value) + override fun decrypt(value: String): String = delegate.decrypt(value) +} + +private class ContentResolverFileSyncGrantAccess(private val resolver: ContentResolver) : + AndroidFileSyncGrantAccess { + override fun exactGrant(uri: String): AndroidFileSyncGrantState { + val target = Uri.parse(uri) + val exact = resolver.persistedUriPermissions.firstOrNull { it.uri == target } + return AndroidFileSyncGrantState(exact?.isReadPermission == true, exact?.isWritePermission == true) + } + + override fun takeExactReadWriteGrant(uri: String) { + resolver.takePersistableUriPermission(Uri.parse(uri), READ_WRITE_GRANT_FLAGS) + } + + override fun releaseExactGrant(uri: String, read: Boolean, write: Boolean) { + resolver.releasePersistableUriPermission(Uri.parse(uri), grantFlags(read, write)) + } +} + +private fun AndroidFileSyncCapabilityRecord.toJson(): JSONObject = JSONObject() + .put("id", id) + .put("uri", uri) + .put("displayName", displayName) + .put("phase", phase.name) + .put("processGeneration", processGeneration) + .put("preExistingReadGrant", preExistingReadGrant) + .put("preExistingWriteGrant", preExistingWriteGrant) + .put("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/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 06cb6de16..2e146c1cb 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 { reconciliationScope.launch { reconcileFileSyncCapabilities(ENGINE_LOCK, 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(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, 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..bf90e7b63 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,82 @@ internal fun deferFileSyncSnapshotActionUntilIdle( return job } +internal suspend fun reconcileFileSyncCapabilities( + lock: Mutex, + load: () -> AndroidFileSyncPersistedState, + capabilities: AndroidFileSyncCapabilityLifecycle, +) { + lock.withLock { + try { + capabilities.reconcile(load()) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + // Fail closed. A later process retries without releasing from incomplete metadata. + } + } +} + +internal 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) -> Unit, +): Boolean { + val commitIsPresent = try { + load().coordinator.pairs.any { it.id == pairId } + } catch (_: Exception) { + return false + } + if (!commitIsPresent) runCatching { abandonUncommittedPair(pairId) } + return commitIsPresent +} + +internal fun bindAndPersistFileSyncPair( + pairId: String, + bindReady: () -> Unit, + persist: () -> Unit, + load: () -> AndroidFileSyncPersistedState, + abandonUncommittedPair: (String) -> Unit, +) { + try { + bindReady() + persist() + } catch (failure: Exception) { + if (recoverFailedFileSyncPairSave(pairId, load, abandonUncommittedPair)) return + throw failure + } +} + +internal fun scheduleCommittedFileSyncPair(schedule: () -> Unit): Boolean = try { + schedule() + true +} catch (failure: CancellationException) { + throw failure +} catch (_: Exception) { + false +} + +internal fun committedFileSyncPairResult(schedule: () -> Unit): FileSyncCenterActionResult { + val scheduled = scheduleCommittedFileSyncPair(schedule) + return FileSyncCenterActionResult.Completed(if (scheduled) { + "Folder sync pair added. Run it to review the first sync." + } else { + "Folder sync pair added. Automatic checks will retry when folder sync status is loaded." + }) +} + /** * Reads a complete atomic snapshot without waiting for active execution. * @@ -212,38 +288,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 +310,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, 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 +341,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/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index 81509c2f4..84d7d37f9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative import android.content.ContentResolver import android.content.Context -import android.content.Intent import android.net.Uri import android.provider.DocumentsContract import androidx.activity.result.ActivityResultLauncher @@ -17,43 +16,58 @@ import kotlin.coroutines.resume * Only the selected tree receives a durable read/write grant. The sync engine never needs broad * storage access for SAF-backed pairs. */ -internal class AndroidFileSyncRootPicker(private val context: Context) { +internal class AndroidFileSyncRootPicker( + private val context: Context, + private val capabilities: AndroidFileSyncCapabilityLifecycle = AndroidFileSyncCapabilityLifecycle(context), +) { private var launcher: ActivityResultLauncher? = null - private var pending: CancellableContinuation? = null + 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)) + capabilities.acquire( + selection.accountId, + uri.toString(), + queryDisplayName(context.contentResolver, uri), + ) + } + result.onSuccess { localRoot -> + resumeFileSyncRootSelection(continuation, localRoot, capabilities::abandonSelection) } - result.onSuccess(continuation::resume) .onFailure { continuation.cancel(it) } } + fun abandon(localRootId: String): Boolean = + abandonAndroidFileSyncRoot(localRootId, capabilities::abandonSelection) + private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { val documentId = DocumentsContract.getTreeDocumentId(treeUri) val documentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId) @@ -68,3 +82,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/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index e4f5acbff..6ec6f8c78 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -58,15 +58,14 @@ internal fun requireAndroidFileSyncAccountRemovalReady( internal class AndroidFileSyncStore internal constructor( private val stateFile: File, private val maximumSnapshotBytes: Int = MAX_SNAPSHOT_BYTES, + private val uploadCleanupStore: AndroidFileSyncUploadCleanupStore = AndroidFileSyncUploadCleanupStore( + File(checkNotNull(stateFile.parentFile), "${stateFile.name}.upload-cleanups"), + ), ) { init { require(maximumSnapshotBytes in 1..MAX_SNAPSHOT_BYTES) } - private val uploadCleanupStore = AndroidFileSyncUploadCleanupStore( - File(checkNotNull(stateFile.parentFile), "${stateFile.name}.upload-cleanups"), - ) - constructor(context: Context) : this(File(context.filesDir, STATE_FILE_NAME)) @Synchronized @@ -123,6 +122,13 @@ internal class AndroidFileSyncStore internal constructor( ) } + @Synchronized + fun loadAndReconcileUploadCleanups(): AndroidFileSyncPersistedState = load().also { state -> + uploadCleanupStore.replace( + state.coordinator.pairs.associate { pair -> pair.id to pair.pendingUploadCleanups }, + ) + } + @Synchronized fun save(state: AndroidFileSyncPersistedState) { val cleanups = state.coordinator.pairs.associate { it.id to it.pendingUploadCleanups } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt index 4ec518aa3..232950257 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncUploadCleanupStore.kt @@ -16,7 +16,10 @@ import java.nio.file.Files import java.nio.file.StandardCopyOption import java.security.MessageDigest -internal class AndroidFileSyncUploadCleanupStore(private val directory: File) { +internal class AndroidFileSyncUploadCleanupStore( + private val directory: File, + private val deleteFile: (File) -> Boolean = File::delete, +) { fun read(): Map> { if (!directory.exists()) return emptyMap() check(directory.isDirectory) { "Folder sync cleanup storage is invalid." } @@ -54,7 +57,7 @@ internal class AndroidFileSyncUploadCleanupStore(private val directory: File) { } checkNotNull(directory.listFiles()) { "Could not list folder sync cleanup storage." } .filter { it.isFile && it.name.endsWith(ROW_SUFFIX) && it.name !in retainedNames } - .forEach { stale -> check(stale.delete()) { "Could not remove obsolete sync cleanup ownership." } } + .forEach { stale -> check(deleteFile(stale)) { "Could not remove obsolete sync cleanup ownership." } } } private fun readRow(file: File): AndroidFileSyncUploadCleanupRow = diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..8f14878e3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1674,12 +1674,13 @@ 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.localRootId) ?: true + override fun retainFileSyncRootOnDispose(): Boolean = activity?.isChangingConfigurations == true + override suspend fun reconcileFileSyncRootSetup(session: NextcloudSession, restoredLocalRoot: FileSyncLocalRoot?) = + withContext(Dispatchers.IO) { reconcileRestoredFileSyncSetup(appContext, session, restoredLocalRoot) } override suspend fun loadIncomingShareRecoveries( session: NextcloudSession, userId: String, @@ -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/AndroidFileSyncCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt new file mode 100644 index 000000000..6e092b536 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncCapabilityLifecycleTest.kt @@ -0,0 +1,921 @@ +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 `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 `legacy duplicates adopt a ready grant without releasing it`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedReady(OLD_GENERATION) + + fixture.lifecycle.reconcile(state(pair(), pair(id = OTHER_PAIR_ID))) + + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + val record = fixture.store.list().single() + assertEquals(AndroidFileSyncCapabilityPhase.Owned, record.phase) + assertEquals(setOf(PAIR_ID, OTHER_PAIR_ID), record.pairIds) + } + + @Test + fun `same uri pair replaces a stale owner without releasing the live grant`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Owned) + + fixture.lifecycle.reconcile(state(pair(id = OTHER_PAIR_ID))) + + assertEquals(setOf(OTHER_PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertFalse("release" in fixture.grants.events) + } + + @Test + fun `owner id attached to another root fails closed`() { + val fixture = fixture(generation = NEW_GENERATION) + fixture.seedOwned(OLD_GENERATION, AndroidFileSyncCapabilityPhase.Owned) + + fixture.lifecycle.reconcile(state(pair(localRootId = "content://example.documents/tree/other"))) + + assertEquals(setOf(PAIR_ID), fixture.store.list().single().pairIds) + assertTrue(fixture.grants.readGranted) + assertTrue(fixture.grants.writeGranted) + assertTrue(fixture.grants.events.isEmpty()) + } + + @Test + fun `unreadable capability state releases nothing`() { + val storage = FakeStorage("unreadable") + val grants = FakeGrantAccess(readGranted = true, writeGranted = true) + val store = AndroidFileSyncCapabilityStore(storage, ThrowingCipher) + val lifecycle = AndroidFileSyncCapabilityLifecycle(store, grants, NEW_GENERATION) + + assertFailsWith { + lifecycle.reconcile(state()) + } + + assertTrue(grants.readGranted) + assertTrue(grants.writeGranted) + assertTrue(grants.events.isEmpty()) + } + + @Test + fun `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 `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) + + assertFailsWith { + fixture.lifecycle.persistPairRemoval(load = { state() }) { + error("save reported failure after commit") + } + } + + assertTrue(fixture.store.list().isEmpty()) + assertFalse(fixture.grants.readGranted) + assertFalse(fixture.grants.writeGranted) + } + + @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, + ): Fixture { + val storage = FakeStorage() + val store = AndroidFileSyncCapabilityStore(storage, IdentityCipher) + val grants = FakeGrantAccess(readGranted, writeGranted) + return Fixture(storage, store, grants, AndroidFileSyncCapabilityLifecycle(store, grants, generation)) + } + + private fun pair(id: String = PAIR_ID, localRootId: String = ROOT_URI) = FileSyncPair( + id = id, + accountId = "account", + localRootId = localRootId, + remoteRootPath = "Notes", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + + private fun state(vararg pairs: FileSyncPair) = AndroidFileSyncPersistedState( + coordinator = dev.obiente.nextcloudnative.app.FileSyncCoordinatorState(pairs.toList()), + localDisplayNames = pairs.associate { it.id to "Notes" }, + ) + + private data class Fixture( + val storage: FakeStorage, + val store: AndroidFileSyncCapabilityStore, + val grants: FakeGrantAccess, + val lifecycle: AndroidFileSyncCapabilityLifecycle, + ) { + fun seedReady( + generation: String, + 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 + + override fun read(): String? = value + + override fun write(value: String): Boolean { + writes += 1 + if (writes == failWriteNumber || writes >= (failWritesFrom ?: Int.MAX_VALUE)) { + if (persistFailedWrite) this.value = value + return false + } + this.value = value + return true + } + } + + private class FakeGrantAccess( + var readGranted: Boolean, + var writeGranted: Boolean, + ) : AndroidFileSyncGrantAccess { + var failQuery = false + var failQueryCount = 0 + var failRelease = false + var failReleaseCount = 0 + val events = mutableListOf() + val releaseRequests = mutableListOf>() + + override fun exactGrant(uri: String): AndroidFileSyncGrantState { + events += "query" + if (failQuery || 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/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index 00ceed34a..07709edb8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -203,6 +203,102 @@ class AndroidFileSyncStoreTest { } } + @Test + fun `postcommit cleanup failure is reconciled from the authoritative empty snapshot`() { + val directory = Files.createTempDirectory("file-sync-cleanup-retry-").toFile() + try { + val stateFile = File(directory, "state.bin") + var failedDeletes = 0 + val cleanupStore = AndroidFileSyncUploadCleanupStore( + File(directory, "state.bin.upload-cleanups"), + deleteFile = { file -> + if (failedDeletes > 0) { + failedDeletes -= 1 + false + } else { + file.delete() + } + }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = cleanupStore) + val owned = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)))) + failedDeletes = 1 + + assertFailsWith { + store.save(AndroidFileSyncPersistedState()) + } + assertTrue(store.load().coordinator.pairs.isEmpty()) + assertTrue(cleanupStore.read().containsKey(owned.id)) + + assertTrue(store.loadAndReconcileUploadCleanups().coordinator.pairs.isEmpty()) + assertTrue(cleanupStore.read().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `restart cleanup preserves rows owned by a retained account pair`() { + val directory = Files.createTempDirectory("file-sync-cleanup-restart-").toFile() + try { + val stateFile = File(directory, "state.bin") + var failDelete = false + val cleanupDirectory = File(directory, "state.bin.upload-cleanups") + val failingRows = AndroidFileSyncUploadCleanupStore( + cleanupDirectory, + deleteFile = { file -> !failDelete && file.delete() }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = failingRows) + val removed = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + val retained = pair().copy( + id = "pair-2", + accountId = "account-2", + remoteRootPath = "Archive", + pendingUploadCleanups = listOf(cleanup("retained.bin", OTHER_UPLOAD_ID)), + ) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(removed, retained)))) + failDelete = true + + assertFailsWith { + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(retained)))) + } + val restarted = AndroidFileSyncStore(stateFile) + + assertEquals(listOf(retained), restarted.loadAndReconcileUploadCleanups().coordinator.pairs) + assertEquals(setOf(retained.id), AndroidFileSyncUploadCleanupStore(cleanupDirectory).read().keys) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `account cleanup retry fails until obsolete upload rows can be deleted`() { + val directory = Files.createTempDirectory("file-sync-account-cleanup-retry-").toFile() + try { + val stateFile = File(directory, "state.bin") + var deletionAvailable = true + val rows = AndroidFileSyncUploadCleanupStore( + File(directory, "state.bin.upload-cleanups"), + deleteFile = { file -> deletionAvailable && file.delete() }, + ) + val store = AndroidFileSyncStore(stateFile, uploadCleanupStore = rows) + val owned = pair().copy(pendingUploadCleanups = listOf(cleanup("removed.bin"))) + store.save(AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)))) + deletionAvailable = false + assertFailsWith { store.save(AndroidFileSyncPersistedState()) } + + assertFailsWith { store.loadAndReconcileUploadCleanups() } + assertTrue(rows.read().containsKey(owned.id)) + deletionAvailable = true + + assertTrue(store.loadAndReconcileUploadCleanups().coordinator.pairs.isEmpty()) + assertTrue(rows.read().isEmpty()) + } finally { + directory.deleteRecursively() + } + } + @Test fun `owned uploads block account removal before pair deletion`() { val accountPair = pair().copy( @@ -237,6 +333,11 @@ class AndroidFileSyncStoreTest { ), ) + private fun cleanup(relativePath: String, uploadId: String = UPLOAD_ID) = FileSyncPendingUploadCleanup( + uploadId = uploadId, + relativePath = relativePath, + ) + private fun withTemporaryStore(block: (AndroidFileSyncStore) -> Unit) { val directory = Files.createTempDirectory("file-sync-store-").toFile() try { @@ -245,4 +346,9 @@ class AndroidFileSyncStoreTest { directory.deleteRecursively() } } + + private companion object { + const val UPLOAD_ID = "01234567-89ab-cdef-0123-456789abcdef" + const val OTHER_UPLOAD_ID = "fedcba98-7654-3210-fedc-ba9876543210" + } } 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/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index d39deeed3..63eae52d1 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 @@ -196,13 +197,14 @@ internal fun FileOfflineCenterScreen( fun beginAddFolderSync() { if (ADD_PAIR_BUSY_ID in syncBusyPairIds) return + if (pendingLocalRoot != null && !abandonPendingFolderSync()) return syncBusyPairIds += ADD_PAIR_BUSY_ID scope.launch { try { - runCatching { services.chooseFileSyncLocalRoot() } + runCatching { services.chooseFileSyncLocalRoot(session) } .onSuccess { selected -> pendingMediaSuggestionJson = null - pendingLocalRootJson = selected?.let { fileSyncSetupJson.encodeToString(it) } + pendingLocalRoot = selected pendingRemotePath = selected?.let { "" } pendingSyncConfigurationJson = selected ?.let { defaultFileSyncConfiguration(isMediaSuggestion = false) } @@ -223,7 +225,7 @@ internal fun FileOfflineCenterScreen( pendingMediaPreview = null mediaPreviewError = null pendingMediaSuggestionJson = fileSyncSetupJson.encodeToString(suggestion) - pendingLocalRootJson = fileSyncSetupJson.encodeToString(suggestion.localRoot) + pendingLocalRoot = suggestion.localRoot pendingRemotePath = suggestion.suggestedRemoteRootPath pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString( defaultFileSyncConfiguration(isMediaSuggestion = true), @@ -470,6 +472,10 @@ internal fun FileOfflineCenterScreen( if (userId.isBlank() || !services.supportsBidirectionalFileSync) return@LaunchedEffect syncLoading = true try { + if (!services.reconcileFileSyncRootSetup(session, pendingLocalRoot)) { + setupDraft.clear() + actionMessage = "Select the local folder again to restore folder access." + } syncSnapshot = services.loadFileSyncCenter(session, userId) } catch (cancelled: CancellationException) { throw cancelled @@ -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/FileSyncRootLifecycle.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt new file mode 100644 index 000000000..93f4b3d25 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import kotlinx.coroutines.CancellationException + +internal class FileSyncSetupDraftState private constructor( + localRoot: FileSyncLocalRoot?, + mediaSuggestionJson: String?, + remotePath: String?, + configurationJson: String?, + remoteFolderPickerVisible: Boolean, + selectionPickerVisible: Boolean, +) { + constructor() : this(null, null, null, null, false, false) + + val localRoot: MutableState = mutableStateOf(localRoot) + val mediaSuggestionJson: MutableState = mutableStateOf(mediaSuggestionJson) + val remotePath: MutableState = mutableStateOf(remotePath) + val configurationJson: MutableState = mutableStateOf(configurationJson) + val remoteFolderPickerVisible: MutableState = mutableStateOf(remoteFolderPickerVisible) + val selectionPickerVisible: MutableState = mutableStateOf(selectionPickerVisible) + + fun clear() { + localRoot.value = null + mediaSuggestionJson.value = null + remotePath.value = null + configurationJson.value = null + remoteFolderPickerVisible.value = false + selectionPickerVisible.value = false + } + + fun abandon(abandonRoot: (FileSyncLocalRoot) -> Boolean): Boolean { + val abandoned = localRoot.value?.let { root -> tryAbandonFileSyncRoot(root, abandonRoot) } ?: true + if (abandoned) { + clear() + } else { + remotePath.value = null + configurationJson.value = null + remoteFolderPickerVisible.value = false + selectionPickerVisible.value = false + } + return abandoned + } + + companion object { + fun restore(saved: List): FileSyncSetupDraftState? { + if (saved.size != SAVED_SETUP_FIELD_COUNT || saved[0] != SAVED_SETUP_VERSION || + saved.sumOf(String::length) > MAX_SAVED_SETUP_CHARACTERS + ) { + return null + } + val root = when (saved[1]) { + "0" -> null + "1" -> runCatching { FileSyncLocalRoot(saved[2], saved[3]) }.getOrNull() ?: return null + else -> return null + } + val remotePath = when (saved[5]) { + "0" -> null + "1" -> saved[6] + else -> return null + } + val remotePickerVisible = saved[8].toBooleanStrictOrNull() ?: return null + val selectionPickerVisible = saved[9].toBooleanStrictOrNull() ?: return null + return FileSyncSetupDraftState( + localRoot = root, + mediaSuggestionJson = saved[4].ifEmpty { null }, + remotePath = remotePath, + configurationJson = saved[7].ifEmpty { null }, + remoteFolderPickerVisible = remotePickerVisible, + selectionPickerVisible = selectionPickerVisible, + ) + } + } +} + +internal fun FileSyncSetupDraftState.savedState(): List? { + val root = localRoot.value + val remote = remotePath.value + val saved = listOf( + SAVED_SETUP_VERSION, + if (root == null) "0" else "1", + root?.localRootId.orEmpty(), + root?.displayName.orEmpty(), + mediaSuggestionJson.value.orEmpty(), + if (remote == null) "0" else "1", + remote.orEmpty(), + configurationJson.value.orEmpty(), + remoteFolderPickerVisible.value.toString(), + selectionPickerVisible.value.toString(), + ) + if (saved.sumOf(String::length) <= MAX_SAVED_SETUP_CHARACTERS) return saved + return listOf( + SAVED_SETUP_VERSION, + if (root == null) "0" else "1", + root?.localRootId.orEmpty(), + root?.displayName.orEmpty(), + "", + "0", + "", + "", + "false", + "false", + ) +} + +internal val FileSyncSetupDraftSaver = Saver>( + save = { draft -> draft.savedState() }, + restore = { saved -> FileSyncSetupDraftState.restore(saved) }, +) + +@Composable +internal fun AbandonFileSyncRootOnDispose( + 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) } + +private fun tryAbandonFileSyncRoot( + root: FileSyncLocalRoot, + abandon: (FileSyncLocalRoot) -> Boolean, +): Boolean = try { + abandon(root) +} catch (failure: CancellationException) { + throw failure +} catch (_: Exception) { + false +} + +private const val SAVED_SETUP_FIELD_COUNT = 10 +private const val MAX_SAVED_SETUP_CHARACTERS = 32 * 1024 +private const val SAVED_SETUP_VERSION = "file-sync-setup-v1" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 27e346602..90aeabf1e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -932,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 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..927a48076 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycleTest.kt @@ -0,0 +1,95 @@ +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") + + 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") + val abandoned = mutableListOf() + + fileSyncRootDisposal( + currentRoot = { deliveredRoot }, + retainRoot = { true }, + abandon = abandoned::add, + ).invoke() + + assertTrue(abandoned.isEmpty()) + } + + @Test + fun `setup draft restores the selected root destination and configuration`() { + val draft = FileSyncSetupDraftState().apply { + localRoot.value = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + mediaSuggestionJson.value = "{\"kind\":\"notes\"}" + remotePath.value = "Shared/Notes" + configurationJson.value = "{\"direction\":\"Bidirectional\"}" + remoteFolderPickerVisible.value = true + selectionPickerVisible.value = true + } + + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + + assertEquals(draft.localRoot.value, restored.localRoot.value) + assertEquals(draft.mediaSuggestionJson.value, restored.mediaSuggestionJson.value) + assertEquals(draft.remotePath.value, restored.remotePath.value) + assertEquals(draft.configurationJson.value, restored.configurationJson.value) + assertTrue(restored.remoteFolderPickerVisible.value) + assertTrue(restored.selectionPickerVisible.value) + } + + @Test + fun `oversized optional setup retains the selected root across recreation`() { + val root = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + configurationJson.value = "x".repeat(32 * 1024) + } + + val restored = assertNotNull(FileSyncSetupDraftState.restore(assertNotNull(draft.savedState()))) + + assertEquals(root, restored.localRoot.value) + assertNull(restored.configurationJson.value) + } + + @Test + fun `failed abandonment keeps the root available for retry`() { + val root = FileSyncLocalRoot("content://example.documents/tree/notes", "Notes") + val draft = FileSyncSetupDraftState().apply { + localRoot.value = root + remotePath.value = "Shared/Notes" + configurationJson.value = "configuration" + remoteFolderPickerVisible.value = true + } + + assertFalse(draft.abandon { false }) + assertEquals(root, draft.localRoot.value) + assertNull(draft.remotePath.value) + assertNull(draft.configurationJson.value) + assertFalse(draft.remoteFolderPickerVisible.value) + + assertFalse(draft.abandon { error("synthetic grant release failure") }) + assertEquals(root, draft.localRoot.value) + + assertTrue(draft.abandon { true }) + assertNull(draft.localRoot.value) + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index b32a2cddc..8c1f1b128 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -2388,7 +2388,7 @@ class DesktopNextcloudServices( true } - override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = + override suspend fun chooseFileSyncLocalRoot(session: NextcloudSession, initialRootHint: String?): FileSyncLocalRoot? = fileSyncEngine.chooseLocalRoot(initialRootHint) override suspend fun loadFileSyncCenter( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt index 14a6f0b6b..cae21bdb3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnosticsTest.kt @@ -317,7 +317,7 @@ class JvmSupportDiagnosticsTest { workers.execute { ready.countDown() start.await() - repeat(20) { index -> + repeat(2) { index -> diagnostics.record( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Warning, @@ -340,10 +340,15 @@ class JvmSupportDiagnosticsTest { assertTrue(ready.await(10L, TimeUnit.SECONDS)) start.countDown() workers.shutdown() - assertTrue(workers.awaitTermination(30L, TimeUnit.SECONDS)) + val completed = try { + workers.awaitTermination(30L, TimeUnit.SECONDS) + } finally { + workers.shutdownNow() + } + assertTrue(completed) - assertEquals(160, diagnostics.summary().eventCount) - assertEquals(160, diagnostics(root).summary().eventCount) + assertEquals(16, diagnostics.summary().eventCount) + assertEquals(16, diagnostics(root).summary().eventCount) assertTrue(File(root, "events-v1.jsonl").length() <= MAX_SUPPORT_DIAGNOSTIC_STORED_BYTES) } diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 81309996e..ade9f8427 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -116,6 +116,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRecovery.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt", @@ -488,7 +489,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", @@ -515,7 +516,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt": "8b529e61c68ec3ee7937fc3695832284b0b7c88841893fe40d3921234b566e51", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "2870ff58c34e2965f11cf9f891cdbfbfd1f91c49e60ae64cdb93c3d5cb7ce313", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "45bfcd7ef28ea73e5514cb3af715e274257609283ac7a857b32327fcff0070e2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueue.kt": "f97b055f4278c8dc7e5b3d4ad4284aaafd0194caf01368754daba9f94632ea24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "1282adb909c54d559d812689ca9f937cda3a1256c1400fd0d0f91ba3a1ace1d6", @@ -538,6 +539,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt": "a05565566bc4b78b8bfbf354360b875df88241fa7ea022bfd992d868f6dd5e57", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPresentation.kt": "a64537a74dc68b0a969f86550e23cee7b2998230bc043c47c54794aff829d55b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRecovery.kt": "fd404b75cb8ead34d94d4395489ad8554b45bd671c28bf426bb8f262f1e4153c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncRootLifecycle.kt": "2a057037578511b359633b19d4ff2f8ec7e3ef7f013aa0e88eaeeb6b506c95f6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSelectionPicker.kt": "33242928be5d216ad664c742212994b1074d7c5d8947f18906c1feb78d922d1b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt": "de8740ee24e98477b7ac3fac51001f7fb8d86a5a2b589905724a621e32ddc7b9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", @@ -645,7 +647,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5370183fb78190a6893e4a36638b0570de4d2d6b33acab18aef52f2b83d83589", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "fa1dabfc1c4fee33c285ce9bb967cdf40b9fa20ba5d2896768d5c24a7d8f6dac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5",