Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ internal class AndroidFileSyncEngine(context: Context) {
private val scheduledMediaReconciliations = ConcurrentHashMap.newKeySet<String>()
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,
Expand Down Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -98,6 +98,82 @@ internal fun <T> 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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve failed uncommitted-pair cleanup

When the authoritative reload confirms that the pair was not committed but abandonUncommittedPair fails or returns false, this runCatching discards the outcome. The capability remains CleanupPending with a nonempty pairIds, while UI abandonment only retries records with no pair IDs, so the user cannot select or release this folder again until a process restart. Fresh evidence in this revision is that the new bind/save recovery helper still erases the cleanup result instead of scheduling reconciliation or surfacing a retryable state.

AGENTS.md reference: AGENTS.md:L281-L283

Useful? React with 👍 / 👎.

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.
*
Expand Down Expand Up @@ -212,83 +288,73 @@ 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)
},
cancelSchedule = { pair -> scheduler.cancel(pair.id) },
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<FileSyncPair> {
capabilities.reconcile(state)
return state.coordinator.pairs.filter { pair -> pair.accountId == accountId }
}

internal suspend fun retireConfiguredFileSyncAccountPairs(
retiredPairs: List<FileSyncPair>,
retainedPairs: List<FileSyncPair>,
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)) {
"A local download still needs safe recovery. Run this folder sync before removing the account."
}
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()
Comment thread
veryCrunchy marked this conversation as resolved.
retiredPairs.forEach { pair -> finishLocalGrantCleanup(pair.id) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Uri?>? = null
private var pending: CancellableContinuation<FileSyncLocalRoot?>? = null
private var pending: PendingFileSyncRootSelection? = null

fun attach(launcher: ActivityResultLauncher<Uri?>) {
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)
Expand All @@ -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<FileSyncLocalRoot?>,
)

internal fun resumeFileSyncRootSelection(
continuation: CancellableContinuation<FileSyncLocalRoot?>,
localRoot: FileSyncLocalRoot,
abandon: (String) -> Unit,
) {
continuation.resume(localRoot) { _, undeliveredRoot, _ ->
runCatching { abandon(undeliveredRoot.localRootId) }
}
}
Loading
Loading