diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 4aabc551f..7b6785057 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -300,6 +300,18 @@ staging files under the sync engine lock, while keeping user originals. These are source and deterministic-test guarantees, not claims that a published installer already includes the behavior. +Deferred Android provider recovery uses the supplied session under the account +removal lease before credentials are persisted. A sync pair retains its SAF +grant until its pending local transactions and retirement have completed. +Provider reconciliation runs outside the sync engine lock. Before retiring an +account or removing a pair, the engine reacquires its lock and verifies that the +selected pair snapshots have not changed; unrelated account state is preserved. + +Legacy self-provider trees owned by a different account recover through their +original provider URI and retained tree permission. Only a root matching the +removing account may receive its supplied recovery session or a local authority +rewrite. An unavailable original provider leaves recovery pending. + Android queued uploads retain their rows while saved credentials require recovery. Malformed preference values, damaged ciphertext, and invalid decoded credential records pause timed retries when no usable or temporarily inaccessible fallback @@ -307,3 +319,44 @@ remains. Temporary keystore failures continue retrying, including for inactive accounts. Unsupported credential versions require an upgrade. These policies are covered by deterministic Android unit tests; they do not establish device or release validation. + +When the removal session is bound to the local provider's account, pending owned +recovery tokens are discovered from that account's root, including directories +moved outside the old sync subtree. The scan retains its depth, count, ownership, +content-authentication, and cancellation bounds. Cross-account or external +provider recovery stays within the original tree grant. + +Expanded retirement discovery indexes only the selected tree's transactions and +legacy transactions not proven to belong elsewhere. Seeing another tree's token +in the same account or directory never authorizes its reconciliation. + +## Recovery authentication and retirement + +Android self-provider recovery bypasses unversioned offline content and cached +fallback reads. It requires a network listing before accepting generation-matched +virtual content or opening an ETag-bound range source. Account-wide recovery-token +discovery rejects multiple observed locations for an owned token without retiring +its ownership record; unrelated tokens remain outside the selected recovery scope. + +Recovery through another local account's legacy provider tree tries that account's +lease without waiting and verifies its exact active session. The lease spans +content authentication and reconciliation. It retains the original tree URI, +grant and discovery scope while using authoritative provider reads. Busy, +unavailable or unverified cross-profile accounts keep recovery pending. Ordinary +external providers retain their existing grant behavior. + +Relocated recovery can be attributed by either an authenticated stage or an +authenticated backup. Backup-only delete transactions do not require a stage; +both the recorded document identity and content identity are still required, +and multiple observed locations remain ambiguous. +Path-changing stage IDs require the original stage name and matching recorded +content. Renamed backup IDs require matching recorded content even when the name +contains the recovery token. Unverified token-bearing candidates preserve the +ownership row without authorizing a rename or deletion. + +Self-provider SAF recovery retains the exact ETag of a successfully completed +content verification and uses it for the later delete or rename precondition. +Failed or cancelled verification invalidates prior proof; a concurrent replacement +cannot contribute its newer ETag to the mutation. Directory recovery without an +authenticated aggregate generation remains pending and preserves its content. +Ordinary external-provider access retains its original platform contract. diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 90766484c..0ebc920c0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -26,13 +26,13 @@ internal class AndroidAccountCredentialController( private val clearPreviewAccount: (String) -> Unit, private val notifyDocumentRootsChanged: () -> Unit, private val resumeQueuedUploads: suspend (String) -> Unit, - private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?, String?) -> Unit, private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?, String?) -> Unit, private val activatePersistedAccount: suspend (NextcloudSession) -> Unit, ) { private val appContext = context.applicationContext + private val accountRemovalLeases = AndroidAccountRemovalLeaseCoordinator(appContext) private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal( preferences = preferences, @@ -160,11 +160,10 @@ internal class AndroidAccountCredentialController( val session = current.sessions[accountId] ?: return@withLock removeUnavailableAccount(accountId, current) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) { + accountRemovalLeases.withLease(session) { val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, - prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { @@ -195,11 +194,11 @@ internal class AndroidAccountCredentialController( val unavailableSession = NextcloudSession(target.record.serverUrl, target.record.loginName, appPassword = "") val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) - withAndroidAccountRemovalLease(accountIdentity) { + accountRemovalLeases.withUnavailableLease(unavailableSession) { removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, active = target.wasActive, - prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + prepareAccountRemoval = {}, removeAccountOwnedWorkWithoutCredentials = { identity -> retryQueuedUploadsCleanupWithoutCredentials( pendingCleanup.accountStorageKey, @@ -233,11 +232,9 @@ internal class AndroidAccountCredentialController( check(current.activeSession == expectedSession) { "The account changed before its remote session could be revoked." } - val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(expectedSession) - revokeAndroidSessionWithAccountLease( - accountIdentity = accountIdentity, - preflight = { prepareAccountRemoval(expectedSession) }, + accountRemovalLeases.revoke( + session = expectedSession, revoke = revokeRemoteSession, removeLocalAccount = { removeAndroidAccountCredentialData( @@ -271,12 +268,10 @@ internal class AndroidAccountCredentialController( if (session == null) { clearSession(read.state) } else { - val accountIdentity = NextcloudDocumentIds.accountKey(session) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(accountIdentity) { + accountRemovalLeases.withLease(session) { removeAndroidAccountCredentialData( active = true, - prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(read.state, pendingCleanup) }, rollbackActiveRemoval = { @@ -336,9 +331,17 @@ internal class AndroidAccountCredentialController( } private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = clearUnregisteredAndroidAccountCredentialSlots( - preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted, - prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure, - ::clearInvalidStore) + preferences = preferences, + sessionCipher = sessionCipher, + cleanupJournal = accountRemovalCleanupJournal, + suspectEncrypted = suspectEncrypted, + prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, + revalidateAccountRemoval = { session -> preflightAndroidAccountRemoval(appContext, session) }, + removeAccountOwnedState = removeQueuedUploads, + commitPreferences = ::commitPreferences, + recordCleanupFailure = ::recordAccountRemovalCleanupFailure, + clearInvalidStore = ::clearInvalidStore, + ) private suspend fun clearRecoveredInvalidStore( current: AndroidAccountCredentialState, @@ -346,11 +349,9 @@ internal class AndroidAccountCredentialController( ) { val activeSession = current.activeSession if (activeSession != null) { - val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) - withAndroidAccountRemovalLease(accountIdentity) { + accountRemovalLeases.withLease(activeSession) { removeRecoveredAndroidAccountCredentialData( - prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) @@ -700,7 +701,7 @@ internal class AndroidAccountCredentialController( private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) = retryAndroidCleanupBeforeActivation(session, accountRemovalCleanupJournal, - { readCredentialFreeRegistry()?.accounts }, prepareAccountRemoval, + { readCredentialFreeRegistry()?.accounts }, { revalidateAndroidAccountRemoval(appContext, it) }, retryQueuedUploadsCleanup, ::recordAccountRemovalCleanupFailure) private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { try { @@ -713,7 +714,6 @@ internal class AndroidAccountCredentialController( throw failure } } - private fun encryptState(state: AndroidAccountCredentialState): String = try { sessionCipher.encrypt(encodeAndroidAccountCredentialState(state)) } catch (failure: Exception) { @@ -723,7 +723,6 @@ internal class AndroidAccountCredentialController( ) throw failure } - private fun encryptCredentialSlot(session: NextcloudSession): String = try { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } catch (failure: Exception) { @@ -785,5 +784,4 @@ internal class AndroidAccountCredentialController( component = SupportDiagnosticComponent.Cache, failure = failure, ) - } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt index 604584105..07b2d0bfb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt @@ -22,7 +22,7 @@ internal suspend fun loadAndroidAccountFileListing( val read: suspend () -> NextcloudFileListing = { readAndroidAccountFileListing(cache, NextcloudDocumentIds.accountKey(session), path, request) } - return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read) + return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read = read) } private suspend fun readAndroidAccountFileListing( @@ -55,3 +55,26 @@ internal fun requireAndroidDocumentDirectory( if (reference.isRoot) return require(findDocument(reference.path).isDirectory) { "The selected parent is not a folder." } } + +internal fun NextcloudFileListing.filesForProviderRecovery(requireNetwork: Boolean): List { + check(!requireNetwork || source == NextcloudFileListingSource.Network) { + "Folder recovery requires a confirmed server listing." + } + return files +} + +internal suspend fun loadAndroidProviderChildren( + recoveryAuthorized: Boolean, + read: suspend () -> List, + cached: () -> List, + storedDirectory: () -> Boolean, +): List = try { + read() +} catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled +} catch (failure: Exception) { + if (recoveryAuthorized) throw failure + val children = cached() + if (children.isNotEmpty() || storedDirectory()) children + else throw java.io.FileNotFoundException("Could not load this Nextcloud folder.").also { it.initCause(failure) } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt index 48efa09f2..a8877da2a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import android.util.Base64 +import dev.obiente.nextcloudnative.app.NextcloudFile import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException @@ -14,9 +15,13 @@ internal suspend fun withRetainedAndroidAccountFileRead( expectedSession: NextcloudSession, resolveSession: suspend () -> NextcloudSession?, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + accountLeaseHeld: Boolean = false, read: suspend () -> Result, ): Result = withContext(Dispatchers.IO) { - guard.withExactAccountSession( + if (accountLeaseHeld) { + check(resolveSession() == expectedSession) { "The account changed before the file read could finish." } + read() + } else guard.withExactAccountSession( expectedSession = expectedSession, resolveSession = resolveSession, unavailable = { error("The account changed before the file read could finish.") }, @@ -85,7 +90,12 @@ internal class AndroidFileRangeSessionCoordinator { val current = synchronized(monitor) { registrations[accountIdentity]?.toList().orEmpty() } current.forEach(Registration::cancel) current.forEach { registration -> registration.awaitDrained() } - synchronized(monitor) { registrations.remove(accountIdentity) } + synchronized(monitor) { + registrations[accountIdentity]?.let { remaining -> + remaining.removeAll(current.toSet()) + if (remaining.isEmpty()) registrations.remove(accountIdentity) + } + } } private fun unregister(accountIdentity: String, registration: Registration) = synchronized(monitor) { @@ -135,11 +145,13 @@ internal fun openTrackedAndroidFileRangeSession( activity: AndroidFileRangeSessionActivity, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, + accountLeaseHeld: Boolean = false, openSource: () -> NextcloudFileRangeSession, ): NextcloudFileRangeSession { - val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) + val lease = if (accountLeaseHeld) null else guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) return try { - if (resolveSession() != expectedSession) { + // Recovery binds a supplied session before it exists in credential storage. + if (!accountLeaseHeld && resolveSession() != expectedSession) { throw FileNotFoundException("The account changed before the file range session could start.") } val source = openSource() @@ -151,7 +163,7 @@ internal fun openTrackedAndroidFileRangeSession( activity.close() throw failure } finally { - lease.close() + lease?.close() } } @@ -159,3 +171,39 @@ internal fun androidFileRangeAuthorization(session: NextcloudSession): String = "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), Base64.NO_WRAP, ) + +internal fun AndroidNextcloudServices.openDocumentProviderFileRangeSession( + session: NextcloudSession, + userId: String, + path: String, + size: Long, + expectedEtag: String, + accountLeaseHeld: Boolean, +): NextcloudFileRangeSession = if (accountLeaseHeld) { + openFileRangeSessionWhileAccountLeaseHeld(session, userId, path, size, expectedEtag) +} else { + openFileRangeSession(session, userId, path, size, expectedEtag) +} + +internal class AndroidFileRangeUnsupportedException(message: String) : Exception(message) + +internal suspend fun probeSeekableExternalHandoffGeneration( + file: NextcloudFile, + verifyEmptyGeneration: suspend () -> Unit, + openRangeSession: (size: Long, etag: String) -> NextcloudFileRangeSession, +): Boolean { + val size = file.size ?: return false + val etag = file.etag?.takeIf(String::isNotBlank) ?: return false + if (size == 0L) { + verifyEmptyGeneration() + return true + } + val rangeSession = openRangeSession(size, etag) + return try { + rangeSession.read(0L, 1).size == 1 + } catch (_: AndroidFileRangeUnsupportedException) { + false + } finally { + rangeSession.close() + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index bb6fd1e02..b568263aa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -54,11 +54,13 @@ internal class AndroidAccountOwnedStateCleanup( legacyAndroidAccountPersistenceScopeDigest(session), ) }, - { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(session) }, { durableUploads.removeForAccount(accountIdentity) }, - { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { retireAndroidFileSyncBeforeGrantRevocation( + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity, session) }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + ) }, { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, @@ -98,11 +100,13 @@ internal class AndroidAccountOwnedStateCleanup( legacyAccountScopeDigest, ) }, - { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity, session) }, { durableUploads.removeForAccount(accountIdentity) }, - { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { retireAndroidFileSyncBeforeGrantRevocation( + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity, session) }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + ) }, { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, @@ -142,11 +146,13 @@ internal class AndroidAccountOwnedStateCleanup( legacyAccountScopeDigest, ) }, - { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity) }, { durableUploads.removeForAccount(accountIdentity) }, - { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { retireAndroidFileSyncBeforeGrantRevocation( + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + ) }, { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, @@ -185,3 +191,11 @@ internal suspend fun runAndroidAccountOwnedStateCleanups( } runAndroidAccountRemovalCleanups(cleanups + previewCleanup) } + +internal suspend fun retireAndroidFileSyncBeforeGrantRevocation( + retire: suspend () -> Unit, + revoke: () -> Unit, +) { + retire() + revoke() +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 6cbb56f40..237c6a11b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -30,6 +30,33 @@ internal suspend fun withAndroidAccountRemovalLease( action = action, ) +internal suspend fun withPreparedAndroidAccountRemovalLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + prepare: suspend () -> Unit, + revalidate: suspend () -> Unit, + action: suspend () -> Result, +): Result { + prepare() + return withAndroidAccountRemovalLease(accountIdentity, guard) { + revalidate() + action() + } +} + +internal suspend fun withUnavailableAndroidAccountRemovalLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + preflight: suspend () -> Unit, + action: suspend () -> Result, +): Result = withPreparedAndroidAccountRemovalLease( + accountIdentity = accountIdentity, + guard = guard, + prepare = preflight, + revalidate = preflight, + action = action, +) + internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, revoke: suspend () -> Unit, @@ -64,11 +91,58 @@ internal suspend fun revokeAndroidSessionAfterRemovalPreflight( internal suspend fun revokeAndroidSessionWithAccountLease( accountIdentity: String, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, - preflight: suspend () -> Unit, + prepare: suspend () -> Unit, + revalidate: suspend () -> Unit, revoke: suspend () -> Unit, removeLocalAccount: suspend () -> Unit, -) = withAndroidAccountRemovalLease(accountIdentity, guard) { - revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) +) = withPreparedAndroidAccountRemovalLease(accountIdentity, guard, prepare, revalidate) { + revokeAndroidSessionAfterRemovalPreflight({}, revoke, removeLocalAccount) +} + +internal class AndroidAccountRemovalLeaseCoordinator( + context: Context, + private val guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, +) { + private val appContext = context.applicationContext + + suspend fun withLease( + session: NextcloudSession, + action: suspend () -> Result, + ): Result = withPreparedAndroidAccountRemovalLease( + accountIdentity = NextcloudDocumentIds.accountKey(session), + guard = guard, + prepare = { prepareAndroidAccountRemoval(appContext, session) }, + revalidate = { revalidateAndroidAccountRemoval(appContext, session) }, + action = action, + ) + + // Missing credentials cannot safely repair legacy self-provider downloads before removal. + // Commit first; durable owned-state cleanup remains fail-closed and can resume after re-add. + suspend fun withUnavailableLease( + session: NextcloudSession, + action: suspend () -> Result, + ): Result = withUnavailableAndroidAccountRemovalLease( + accountIdentity = NextcloudDocumentIds.accountKey(session), + guard = guard, + preflight = { + preflightAndroidAccountRemoval(appContext, session) + ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) + }, + action = action, + ) + + suspend fun revoke( + session: NextcloudSession, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, + ) = revokeAndroidSessionWithAccountLease( + accountIdentity = NextcloudDocumentIds.accountKey(session), + guard = guard, + prepare = { prepareAndroidAccountRemoval(appContext, session) }, + revalidate = { revalidateAndroidAccountRemoval(appContext, session) }, + revoke = revoke, + removeLocalAccount = removeLocalAccount, + ) } internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) { @@ -88,6 +162,11 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { preflightAndroidAccountRemoval(context, session) + reconcileAndroidFileSyncAccountDownloadsBeforeCredentialRemoval( + context, + NextcloudDocumentIds.accountKey(session), + session, + ) ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) } @@ -115,3 +194,11 @@ internal suspend fun runAndroidAccountRemovalCleanups( } firstFailure?.let { throw it } } + +internal suspend fun revalidateAndroidAccountRemoval(context: Context, session: NextcloudSession) { + preflightAndroidAccountRemoval(context, session) + reconcileAndroidFileSyncAccountDownloadsBeforeCredentialRemoval( + context, NextcloudDocumentIds.accountKey(session), session, accountLeaseHeld = true, + ) + ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecovery.kt new file mode 100644 index 000000000..c242e8567 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecovery.kt @@ -0,0 +1,25 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.runBlocking + +/** Never wait for a second account while account removal may already own another lease. */ +internal fun withAndroidCrossAccountProviderRecovery( + rootDocumentId: String, + sameProfile: Boolean, + resolveSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: (NextcloudSession) -> Result, +): Result { + check(sameProfile) { "Cross-profile recovery needs authoritative provider access." } + val session = checkNotNull(resolveSession()) { "The recovery account is unavailable." } + check(androidRootBoundProviderRecoverySession(rootDocumentId, session) != null) { "The recovery account changed." } + return runBlocking { + guard.tryWithAccount(NextcloudDocumentIds.accountKey(session), unavailable = { + error("The recovery account is busy. Retry after its active operation finishes.") + }) { + check(resolveSession() == session) { "The recovery account changed." } + action(session) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 6795c95c9..47522dac2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -59,9 +59,10 @@ internal fun acquireAndroidDocumentMutationAccountLease( internal inline fun withAndroidDocumentMutation( session: NextcloudSession, noinline loadCurrentSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, action: (NextcloudSession) -> Result, ): Result { - val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession) + val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession, guard) return try { action(session) } finally { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderContentRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderContentRecovery.kt new file mode 100644 index 000000000..cb55dc415 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderContentRecovery.kt @@ -0,0 +1,7 @@ +package dev.obiente.nextcloudnative + +/** Recovery authentication may read cached bytes only after matching an authoritative generation. */ +internal inline fun readAndroidUnversionedProviderContent( + recoveryAuthorized: Boolean, + readCached: () -> Content?, +): Content? = if (recoveryAuthorized) null else readCached() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderSessionBinding.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderSessionBinding.kt new file mode 100644 index 000000000..bb7815180 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderSessionBinding.kt @@ -0,0 +1,325 @@ +package dev.obiente.nextcloudnative + +import android.net.Uri +import android.os.Binder +import android.os.Process +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException + +internal enum class AndroidDocumentsProviderRecoveryOperation { + QueryDocument, + QueryChildren, + OpenRead, + OpenWrite, + Create, + Rename, + Delete, + Move, +} + +internal data class AndroidDocumentsProviderResolvedSession( + val session: NextcloudSession, + val recoveryAuthorized: Boolean, +) + +private class AndroidDocumentsProviderRecoveryPermit( + val session: NextcloudSession, + val documentId: String, + val operation: AndroidDocumentsProviderRecoveryOperation, + val generations: AndroidProviderRecoveryGenerations? = null, + var consumed: Boolean = false, +) + +private val ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS = + ThreadLocal>() + +internal class AndroidDocumentsProviderRecoveryAccess( + private val session: NextcloudSession?, + private val localAuthority: String? = null, + private val preserveTreeGrant: Boolean = false, +) { + private val generations = AndroidProviderRecoveryGenerations() + + fun run( + document: Uri, + operation: AndroidDocumentsProviderRecoveryOperation, + action: (Uri) -> Result, + ): Result { + val documentId = DocumentsContract.getDocumentId(document) + val ordinaryUri = androidDocumentsProviderRecoveryUri( + documentId = documentId, + operation = operation, + buildDocumentUri = { document }, + buildChildDocumentsUri = { id -> DocumentsContract.buildChildDocumentsUriUsingTree(document, id) }, + ) + val bound = session ?: return action(ordinaryUri) + if (preserveTreeGrant) return withAndroidDocumentsProviderRecoveryPermit(bound, documentId, operation, generations) { action(ordinaryUri) } + val authority = androidLocalRecoveryAuthority(requireNotNull(document.authority), requireNotNull(localAuthority)) + val recoveryUri = androidDocumentsProviderRecoveryUri( + documentId = documentId, + operation = operation, + buildDocumentUri = { id -> DocumentsContract.buildDocumentUri(authority, id) }, + buildChildDocumentsUri = { id -> DocumentsContract.buildChildDocumentsUri(authority, id) }, + ) + return withAndroidDocumentsProviderRecoveryPermit(bound, documentId, operation, generations) { + action(recoveryUri) + } + } + + fun normalizeResult(document: Uri, result: Uri?): Uri? = + normalizeAndroidDocumentsProviderRecoveryResult( + recoveryEnabled = session != null, + document = document, + result = result, + documentIdOf = DocumentsContract::getDocumentId, + buildTreeDocumentUri = DocumentsContract::buildDocumentUriUsingTree, + ) +} + +internal fun androidDocumentsProviderRecoveryUri( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + buildDocumentUri: (String) -> Uri, + buildChildDocumentsUri: (String) -> Uri, +): Uri = when (operation) { + AndroidDocumentsProviderRecoveryOperation.QueryChildren -> buildChildDocumentsUri(documentId) + AndroidDocumentsProviderRecoveryOperation.OpenRead, + AndroidDocumentsProviderRecoveryOperation.Rename, + AndroidDocumentsProviderRecoveryOperation.Delete, + -> buildDocumentUri(documentId) + AndroidDocumentsProviderRecoveryOperation.QueryDocument, + AndroidDocumentsProviderRecoveryOperation.OpenWrite, + AndroidDocumentsProviderRecoveryOperation.Create, + AndroidDocumentsProviderRecoveryOperation.Move, + -> error("The document operation is not permitted for recovery.") +} + +internal fun normalizeAndroidDocumentsProviderRecoveryResult( + recoveryEnabled: Boolean, + document: Uri, + result: Uri?, + documentIdOf: (Uri) -> String, + buildTreeDocumentUri: (Uri, String) -> Uri, +): Uri? = result?.let { renamed -> + if (recoveryEnabled) buildTreeDocumentUri(document, documentIdOf(renamed)) else renamed +} + +/** + * Grants one exact provider operation to synchronous self-provider recovery. The provider must stay + * in this app process because the one-shot authority intentionally cannot cross thread boundaries. + */ +internal fun withAndroidDocumentsProviderRecoveryPermit( + session: NextcloudSession, + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + generations: AndroidProviderRecoveryGenerations? = null, + action: () -> Result, +): Result { + NextcloudDocumentIds.requireForSession(documentId, session) + requireAndroidDocumentsProviderRecoveryOperation(operation) + val permit = AndroidDocumentsProviderRecoveryPermit(session, documentId, operation, generations) + val permits = ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.get() + ?: mutableListOf().also { created -> + ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.set(created) + } + permits += permit + return try { + if (generations == null) action() else generations.run(documentId, operation, action) + } finally { + check(permits.remove(permit)) { "The document recovery permit was already cleared." } + if (permits.isEmpty()) ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.remove() + } +} + +internal fun resolveAndroidDocumentsProviderSession( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + allowRecoveryPermit: Boolean, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession? { + val accountIdentity = runCatching { NextcloudDocumentIds.parse(documentId).accountKey }.getOrNull() + ?: return null + if (allowRecoveryPermit) { + ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.get() + .orEmpty() + .asReversed() + .firstOrNull { permit -> + !permit.consumed && permit.documentId == documentId && permit.operation == operation + } + ?.let { permit -> + permit.consumed = true + return AndroidDocumentsProviderResolvedSession(permit.session, recoveryAuthorized = true) + } + } + loadActiveSession()?.takeIf { session -> + NextcloudDocumentIds.accountKey(session) == accountIdentity + }?.let { session -> return AndroidDocumentsProviderResolvedSession(session, recoveryAuthorized = false) } + return null +} + +private fun requireAndroidDocumentsProviderRecoveryOperation( + operation: AndroidDocumentsProviderRecoveryOperation, +) { + require( + operation == AndroidDocumentsProviderRecoveryOperation.QueryChildren || + operation == AndroidDocumentsProviderRecoveryOperation.OpenRead || + operation == AndroidDocumentsProviderRecoveryOperation.Rename || + operation == AndroidDocumentsProviderRecoveryOperation.Delete, + ) { "The document operation is not permitted for recovery." } +} + +internal fun resolveAndroidDocumentsProviderSessionForCaller( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession? = resolveAndroidDocumentsProviderSession( + documentId = documentId, + operation = operation, + allowRecoveryPermit = Binder.getCallingUid() == Process.myUid(), + loadActiveSession = loadActiveSession, +) + +internal fun requireAndroidDocumentsProviderSession( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession = resolveAndroidDocumentsProviderSessionForCaller( + documentId, + operation, + loadActiveSession, +) ?: throw FileNotFoundException("This Nextcloud document is not available for the active account.") + +internal fun requireAndroidDocumentsProviderCallSession( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession = + if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { + val session = loadActiveSession() ?: throw FileNotFoundException("Sign in to nati.ve to browse files.") + AndroidDocumentsProviderResolvedSession(session, recoveryAuthorized = false) + } else { + requireAndroidDocumentsProviderSession(documentId, operation, loadActiveSession) + } + +internal fun withAndroidDocumentsProviderMutation( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + loadActiveSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: (NextcloudSession) -> Result, +): Result { + val resolved = requireAndroidDocumentsProviderSession(documentId, operation, loadActiveSession) + return withResolvedAndroidDocumentsProviderMutation(resolved, loadActiveSession, guard, action) +} + +internal fun withResolvedAndroidDocumentsProviderMutation( + resolved: AndroidDocumentsProviderResolvedSession, + loadActiveSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard, + action: (NextcloudSession) -> Result, +): Result { + if (resolved.recoveryAuthorized) return action(resolved.session) + return withAndroidDocumentMutation( + resolved.session, + loadActiveSession, + guard, + action, + ) +} + +internal fun requireAndroidDocumentsProviderQuerySession( + documentId: String, + loadActiveSession: () -> NextcloudSession?, +): NextcloudSession = requireAndroidDocumentsProviderCallSession( + documentId, + AndroidDocumentsProviderRecoveryOperation.QueryDocument, + loadActiveSession, +).session + +internal fun requireAndroidDocumentsProviderChildrenSession( + documentId: String, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession = requireAndroidDocumentsProviderSession( + documentId, + AndroidDocumentsProviderRecoveryOperation.QueryChildren, + loadActiveSession, +) + +internal fun requireAndroidDocumentsProviderOpenSession( + documentId: String, + mode: String, + loadActiveSession: () -> NextcloudSession?, +): AndroidDocumentsProviderResolvedSession = requireAndroidDocumentsProviderCallSession( + documentId, + if (mode == "r") AndroidDocumentsProviderRecoveryOperation.OpenRead else + AndroidDocumentsProviderRecoveryOperation.OpenWrite, + loadActiveSession, +) + +internal fun withAndroidDocumentsProviderCreate( + documentId: String, + loadActiveSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result = withAndroidDocumentsProviderMutation( + documentId, AndroidDocumentsProviderRecoveryOperation.Create, loadActiveSession, action = action, +) + +internal fun withAndroidDocumentsProviderRename( + documentId: String, + loadActiveSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result = withAndroidDocumentsProviderMutation( + documentId, AndroidDocumentsProviderRecoveryOperation.Rename, loadActiveSession, action = action, +) + +internal fun withAndroidDocumentsProviderDelete( + documentId: String, + loadActiveSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result = withAndroidDocumentsProviderMutation( + documentId, AndroidDocumentsProviderRecoveryOperation.Delete, loadActiveSession, action = action, +) + +internal fun withAndroidDocumentsProviderMove( + documentId: String, + loadActiveSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result = withAndroidDocumentsProviderMutation( + documentId, AndroidDocumentsProviderRecoveryOperation.Move, loadActiveSession, action = action, +) + +/** A bound recovery session addresses the same remote document through this profile's provider. */ +internal fun androidLocalRecoveryAuthority(authority: String, localAuthority: String): String { + val parts = authority.split('@') + require(parts.size in 1..2 && (parts.size == 1 || parts[0].isNotEmpty() && parts[0].all { it in '0'..'9' })) { + "The recovery provider profile is invalid." + } + require(parts.last() == localAuthority) { "The recovery provider authority does not match this app." } + return localAuthority +} + +/** A different account's legacy tree must keep using its original provider and grant. */ +internal fun androidRootBoundProviderRecoverySession( + rootDocumentId: String, + removingSession: NextcloudSession?, +): NextcloudSession? { + if (removingSession == null) return null + val reference = NextcloudDocumentIds.parse(rootDocumentId) + return removingSession.takeIf { reference.accountKey == NextcloudDocumentIds.accountKey(it) } +} + +internal fun recordAndroidProviderRecoveryReadGeneration(documentId: String, etag: String?) { + ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.get().orEmpty().lastOrNull { + it.consumed && it.documentId == documentId && it.operation == AndroidDocumentsProviderRecoveryOperation.OpenRead + }?.generations?.recordReadGeneration(documentId, etag) +} + +internal fun androidProviderRecoveryMutationEtag(documentId: String, currentEtag: String, isDirectory: Boolean): String { + val permit = ANDROID_DOCUMENTS_PROVIDER_RECOVERY_PERMITS.get().orEmpty().lastOrNull { + it.consumed && it.documentId == documentId && + (it.operation == AndroidDocumentsProviderRecoveryOperation.Rename || it.operation == AndroidDocumentsProviderRecoveryOperation.Delete) + } ?: return currentEtag + return requireNotNull(permit.generations) { "Recovery mutation has no authenticated generation." } + .mutationEtag(documentId, isDirectory) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 06cb6de16..a56902ca9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -37,7 +37,6 @@ import dev.obiente.nextcloudnative.app.currentFileSyncContentVerificationResults import dev.obiente.nextcloudnative.app.failFileSyncOperation import dev.obiente.nextcloudnative.app.fileSyncContentVerificationCandidates import dev.obiente.nextcloudnative.app.fileSyncOwnedUploads -import dev.obiente.nextcloudnative.app.removeFileSyncPair import dev.obiente.nextcloudnative.app.resolveFileSyncDecisions import dev.obiente.nextcloudnative.app.retryFileSyncOperation import dev.obiente.nextcloudnative.app.scanFileSyncPair @@ -301,76 +300,8 @@ internal class AndroidFileSyncEngine(context: Context) { } suspend fun removePair(session: NextcloudSession, userId: String, pairId: String): FileSyncCenterActionResult = - ENGINE_LOCK.withLock { - val current = store.load() - val pair = current.coordinator.pairs.firstOrNull { it.id == pairId } - ?: return@withLock FileSyncCenterActionResult.Rejected( - "The folder sync pair no longer exists.", - ) - if (pair.accountId != NextcloudDocumentIds.accountKey(session)) { - return@withLock FileSyncCenterActionResult.Rejected( - "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 } - var cleanedCoordinator: FileSyncCoordinatorState? = null - var remoteCleanupRejected = false - val removed = removeConfiguredFileSyncPair( - reconcileLocalDownloads = { - reconcileSafDownloadsBeforePairRemoval(appContext, pair.localRootId) - }, - cleanRemoteUploads = { - val cleanupResult = cleanupJvmFileSyncOwnedUploads( - androidFileSyncOwnedRemoteTree(session, userId, pair, webDav, context = appContext), - current.coordinator, pairId, fileSyncOwnedUploads(pair), - ) - remoteCleanupRejected = cleanupResult.unresolvedUploads.isNotEmpty() - if (!remoteCleanupRejected) cleanedCoordinator = cleanupResult.state - !remoteCleanupRejected - }, - cleanLedger = { - val mediaStore = createAndroidMediaBackupLedgerStore( - context = appContext, - recoverInterruptedTransfers = false, - ) - try { - mediaStore.deleteUnfinishedSource( - accountId = pair.accountId, - sourceId = pair.id, - legacyLocalKeys = (pair.baselines.asSequence().map(FileSyncBaseline::relativePath) + - pair.workItems.asSequence().map { work -> work.relativePath }) - .distinct() - .map { relativePath -> - legacyMediaBackupLocalKey(pair.localRootId, relativePath) - } - .toList(), - ) - } finally { - mediaStore.close() - } - }, - persistRemoval = { - val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) - store.save( - current.copy( - coordinator = remaining, - localDisplayNames = current.localDisplayNames - pairId, - ), - ) - }, - cancelSchedule = { scheduler.cancel(pairId) }, - releaseLocalGrant = { - releaseSafGrantAfterPairRemoval(appContext, pair.localRootId, releasesLocalGrant) - }, - ) - if (!removed) { - return@withLock FileSyncCenterActionResult.Rejected(if (remoteCleanupRejected) { - "A previous upload still needs safe recovery. Run this folder sync before removing it." - } else "A local download still needs safe recovery. Run this folder sync before removing it.") - } - FileSyncCenterActionResult.Completed("Folder sync pair removed. No local or server files were deleted.") - } + removeAndroidConfiguredFileSyncPair(appContext, store, webDav, scheduler, session, userId, pairId) + suspend fun runPair( session: NextcloudSession, userId: String, @@ -410,6 +341,9 @@ internal class AndroidFileSyncEngine(context: Context) { FileSyncRejectionScope.Preflight, ) } + val rejection = androidFileSyncRootRejection(initialPair.localRootId, appContext.packageName) + if (rejection != null) return FileSyncCenterActionResult.Rejected(rejection.message, FileSyncRejectionScope.Preflight) + val local = createAndroidFileSyncLocalTree(appContext, initialPair.localRootId) return withAndroidMediaBackupLedger(appContext, initialPair) { mediaLedger -> val remote = androidFileSyncOwnedRemoteTree( session, userId, initialPair, webDav, @@ -432,7 +366,6 @@ internal class AndroidFileSyncEngine(context: Context) { configuration.includesSyncPath(relativePath, kind) } val remoteEntries = remote.scan(includes).map(AndroidRemoteSyncDocument::entry) - val local = createAndroidFileSyncLocalTree(appContext, initialPair.localRootId) val contentReadBudget = AndroidFileSyncContentReadBudget() val scannedLocalDocuments = local.scan(includes, remote::shouldContinueTransfer) val strengthenedLocalDocuments = strengthenAndroidFileSyncReplacementEntries( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index ed2e2125b..8cd085d67 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -6,6 +6,7 @@ import android.net.Uri import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -160,6 +161,8 @@ internal suspend fun commitConfiguredFileSyncPairRemoval( internal suspend fun reconcileSafDownloadsBeforePairRemoval( context: Context, localRootId: String, + localRecoveryPaths: Set, + providerRecoverySession: NextcloudSession? = null, ): Boolean { if (!localRootId.startsWith("content://")) return true val shouldContinue = androidFileSyncJobContinuation(currentCoroutineContext()[Job]) @@ -187,7 +190,20 @@ internal suspend fun reconcileSafDownloadsBeforePairRemoval( } if (!shouldContinue()) throw CancellationException("Pair removal was cancelled.") val reconciled = reconcileSafDownloadsBeforePairRemoval(hasPersistedGrant, hasPendingRecovery) { - createAndroidFileSyncLocalTree(context, localRootId).reconcileOwnedDownloads(shouldContinue) + if ( + androidPickerUriRejection(localRootId, context.applicationContext.packageName) == + AndroidPickerUriRejection.OwnDocumentsProvider + ) { + reconcileOwnProviderSafDownloadsBeforePairRemoval( + context = context, + localRootId = localRootId, + localRecoveryPaths = localRecoveryPaths, + shouldContinue = shouldContinue, + providerRecoverySession = providerRecoverySession, + ) + } else { + createAndroidFileSyncLocalTree(context, localRootId).reconcileOwnedDownloads(shouldContinue) + } } if (!shouldContinue()) throw CancellationException("Pair removal was cancelled.") return reconciled @@ -231,22 +247,31 @@ internal fun releaseSafGrantAfterPairRemoval( } } -internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String) { - AndroidFileSyncEngine.ENGINE_LOCK.withLock { - val store = AndroidFileSyncStore(context) +internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String, providerRecoverySession: NextcloudSession? = null) { + val store = AndroidFileSyncStore(context) + fun accountPairs() = store.load().coordinator.pairs.filter { it.accountId == accountId } + val snapshot = AndroidFileSyncEngine.ENGINE_LOCK.withLock { accountPairs() } + if (snapshot.isEmpty()) return + withRecoveredFileSyncPairSnapshot( + lock = AndroidFileSyncEngine.ENGINE_LOCK, + snapshot = snapshot, + readCurrentSnapshot = ::accountPairs, + reconcile = { pair -> + reconcileSafDownloadsBeforePairRemoval( + context, pair.localRootId, androidSafOwnedDownloadRecoveryPaths(pair), providerRecoverySession, + ) + }, + onRecoveryRejected = { error("A local download still needs safe recovery. Run this folder sync before removing the account.") }, + onSnapshotChanged = { error("Folder sync changed during recovery. Review it before removing the account.") }, + ) { val current = store.load() - val (retiredPairs, retainedPairs) = current.coordinator.pairs.partition { pair -> - pair.accountId == accountId - } - if (retiredPairs.isEmpty()) return@withLock + val retainedPairs = current.coordinator.pairs.filter { it.accountId != accountId } val scheduler = AndroidFileSyncScheduler(context) val notifications = AndroidNotificationCoordinator(context) retireConfiguredFileSyncAccountPairs( - retiredPairs = retiredPairs, + retiredPairs = snapshot, retainedPairs = retainedPairs, - reconcileLocalDownloads = { pair -> - reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) - }, + reconcileLocalDownloads = { true }, cancelSchedule = { pair -> scheduler.cancel(pair.id) }, cancelNotification = { pair -> notifications.cancel(pair.accountId, androidFileSyncNotificationId(pair.id)) @@ -259,6 +284,64 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account } } +internal suspend fun reconcileAndroidFileSyncAccountDownloadsBeforeCredentialRemoval( + context: Context, + accountId: String, + providerRecoverySession: NextcloudSession, + accountLeaseHeld: Boolean = false, +) { + val store = AndroidFileSyncStore(context) + fun accountPairs() = store.load().coordinator.pairs.filter { it.accountId == accountId } + if (AndroidFileSyncEngine.ENGINE_LOCK.withLock { accountPairs().isEmpty() }) return + val services = AndroidNextcloudServices(context.applicationContext) + withAndroidFileSyncAccountRecoveryLease( + expectedSession = providerRecoverySession, + resolveSession = { services.loadSession(providerRecoverySession.accountId) }, + accountLeaseHeld = accountLeaseHeld, + ) { + val snapshot = AndroidFileSyncEngine.ENGINE_LOCK.withLock { accountPairs() } + withRecoveredFileSyncPairSnapshot( + lock = AndroidFileSyncEngine.ENGINE_LOCK, + snapshot = snapshot, + readCurrentSnapshot = ::accountPairs, + reconcile = { pair -> + reconcileSafDownloadsBeforePairRemoval( + context, pair.localRootId, androidSafOwnedDownloadRecoveryPaths(pair), providerRecoverySession, + ) + }, + onRecoveryRejected = { error("A local download still needs safe recovery. Run this folder sync before removing the account.") }, + onSnapshotChanged = { error("Folder sync changed during recovery. Review it before removing the account.") }, + commit = {}, + ) + } +} + +internal suspend fun withAndroidFileSyncAccountRecoveryLease( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + accountLeaseHeld: Boolean = false, + action: suspend () -> Result, +): Result = if (accountLeaseHeld) action() else guard.withExactAccountSession( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before folder sync recovery could start.") }, +) { action() } + +internal suspend fun reconcileConfiguredFileSyncAccountDownloadsBeforeCredentialRemoval( + pairs: List, + accountId: String, + reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, +) { + require(accountId.isNotBlank()) + pairs.filter { pair -> pair.accountId == accountId }.forEach { pair -> + check(reconcileLocalDownloads(pair)) { + "A local download still needs safe recovery. Run this folder sync before removing the account." + } + currentCoroutineContext().ensureActive() + } +} + internal suspend fun retireConfiguredFileSyncAccountPairs( retiredPairs: List, retainedPairs: List, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt index 1eda6efa1..ca083268f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt @@ -4,6 +4,7 @@ import android.content.ContentResolver import android.net.Uri import android.provider.DocumentsContract import dev.obiente.nextcloudnative.app.LocalSyncEntry +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SyncEntryKind import dev.obiente.nextcloudnative.app.hashExactJvmFileSyncSlice import dev.obiente.nextcloudnative.app.normalizeSyncSha256 @@ -27,10 +28,16 @@ internal class AndroidSafFileSyncLocalTree( private val resolver: ContentResolver, rootId: String, private val downloadOwnershipStore: AndroidSafDownloadOwnershipStore, + providerRecoverySession: NextcloudSession? = null, + localRecoveryAuthority: String? = null, + preserveProviderTreeGrant: Boolean = false, ) : AndroidFileSyncLocalTree { private val treeUri = Uri.parse(rootId) private val rootDocumentId = DocumentsContract.getTreeDocumentId(treeUri) private val rootUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocumentId) + private val providerRecovery = AndroidDocumentsProviderRecoveryAccess( + androidRootBoundProviderRecoverySession(rootDocumentId, providerRecoverySession), localRecoveryAuthority, preserveProviderTreeGrant, + ) init { require(rootId.startsWith("content://")) { "The local sync root is not a document-tree grant." } @@ -70,11 +77,12 @@ internal class AndroidSafFileSyncLocalTree( private fun indexRecoveryLocations( ownershipDirectory: AndroidSafDownloadOwnershipDirectory, shouldContinue: () -> Boolean, + discoveryRoot: Uri, ) { val pending = ArrayDeque>() val visited = mutableSetOf() var observedEntries = 0 - pending += "" to rootUri + pending += "" to discoveryRoot while (pending.isNotEmpty()) { requireScanContinuation(shouldContinue) val (parentPath, parentUri) = pending.removeFirst() @@ -98,11 +106,12 @@ internal class AndroidSafFileSyncLocalTree( } } - private fun indexRecoveryLocationsIfNeeded( + internal fun indexRecoveryLocationsIfNeeded( ownershipDirectory: AndroidSafDownloadOwnershipDirectory, shouldContinue: () -> Boolean, + discoveryRoot: Uri = rootUri, ) = indexAndroidSafRecoveryLocationsIfNeeded(ownershipDirectory) { - indexRecoveryLocations(ownershipDirectory, shouldContinue) + indexRecoveryLocations(ownershipDirectory, shouldContinue, discoveryRoot) } override fun authenticateFileForReplacement( @@ -485,10 +494,15 @@ internal class AndroidSafFileSyncLocalTree( document: AndroidLocalSyncDocument, shouldContinue: () -> Boolean, ): String { - return requireNotNull(resolver.openInputStream(document.uri)) { - "The local replacement item could not be opened for verification." - }.use { input -> - hashAndroidSafReplacementContent(input, document.entry.size, shouldContinue) + return providerRecovery.run( + document.uri, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { recoveryUri -> + requireNotNull(resolver.openInputStream(recoveryUri)) { + "The local replacement item could not be opened for verification." + }.use { input -> + hashAndroidSafReplacementContent(input, document.entry.size, shouldContinue) + } } } @@ -571,7 +585,7 @@ internal class AndroidSafFileSyncLocalTree( return listedChildren.filter { it.uri in visibleUris } } - private fun downloadPublisher( + internal fun downloadPublisher( parentUri: Uri, parentPath: String, shouldContinue: () -> Boolean = { !Thread.currentThread().isInterrupted }, @@ -594,10 +608,13 @@ internal class AndroidSafFileSyncLocalTree( ?.let { child -> androidSafReplacementContentIdentity(replacementSnapshot(child, shouldContinue)) } private fun rawChildren(parentUri: Uri, parentPath: String): List { - val parentId = DocumentsContract.getDocumentId(parentUri) - val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentId) - val cursor = requireNotNull(resolver.query(childrenUri, PROJECTION, null, null, null)) { - "The local file provider could not list the selected folder." + val cursor = providerRecovery.run( + parentUri, + AndroidDocumentsProviderRecoveryOperation.QueryChildren, + ) { recoveryUri -> + requireNotNull(resolver.query(recoveryUri, PROJECTION, null, null, null)) { + "The local file provider could not list the selected folder." + } } return cursor.use { buildList { @@ -636,34 +653,18 @@ internal class AndroidSafFileSyncLocalTree( private fun publicationDirectory( parentUri: Uri, parentPath: String, - ): AndroidSafPublicationDirectory = object : AndroidSafPublicationDirectory { - override fun documents(): List> = + ): AndroidSafPublicationDirectory = AndroidSafFileSyncPublicationDirectory( + resolver = resolver, + parentUri = parentUri, + documents = { rawChildren(parentUri, parentPath).map { document -> AndroidSafPublicationDocument(document.uri, document.displayName) } - - override fun createFile(displayName: String): Uri = requireNotNull( - DocumentsContract.createDocument( - resolver, - parentUri, - "application/octet-stream", - displayName, - ), - ) { "A staged local file could not be created." } - - override fun createDirectory(displayName: String): Uri = requireNotNull( - createDirectoryDocument(parentUri, displayName), - ) { "A staged local folder could not be created." } - - override fun writeFile(document: Uri, write: (OutputStream) -> Unit) { - writeDocument(document, write) - } - - override fun rename(document: Uri, displayName: String): Uri? = - DocumentsContract.renameDocument(resolver, document, displayName) - - override fun delete(document: Uri): Boolean = DocumentsContract.deleteDocument(resolver, document) - } + }, + createDirectory = { displayName -> createDirectoryDocument(parentUri, displayName) }, + writeDocument = ::writeDocument, + providerRecovery = providerRecovery, + ) private fun createDirectoryDocument(parentUri: Uri, displayName: String): Uri? = DocumentsContract.createDocument( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecovery.kt new file mode 100644 index 000000000..64a7499ac --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecovery.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncPair +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Provider reads can acquire another account lease, so they must not hold the engine lock. */ +internal suspend fun withRecoveredFileSyncPairSnapshot( + lock: Mutex, + snapshot: List, + readCurrentSnapshot: () -> List, + reconcile: suspend (FileSyncPair) -> Boolean, + onRecoveryRejected: () -> Result, + onSnapshotChanged: () -> Result, + commit: suspend () -> Result, +): Result { + for (pair in snapshot) { + if (!reconcile(pair)) return onRecoveryRejected() + currentCoroutineContext().ensureActive() + } + return lock.withLock { + if (readCurrentSnapshot() != snapshot) onSnapshotChanged() else commit() + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRemoval.kt new file mode 100644 index 000000000..4961bbd35 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRemoval.kt @@ -0,0 +1,101 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.FileSyncBaseline +import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult +import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.cleanupJvmFileSyncOwnedUploads +import dev.obiente.nextcloudnative.app.fileSyncOwnedUploads +import dev.obiente.nextcloudnative.app.removeFileSyncPair +import kotlinx.coroutines.sync.withLock + +internal suspend fun removeAndroidConfiguredFileSyncPair( + appContext: Context, + store: AndroidFileSyncStore, + webDav: NextcloudDocumentWebDav, + scheduler: AndroidFileSyncScheduler, + session: NextcloudSession, + userId: String, + pairId: String, +): FileSyncCenterActionResult = run { + val pair = AndroidFileSyncEngine.ENGINE_LOCK.withLock { store.load().coordinator.pairs.firstOrNull { it.id == pairId } } + ?: return@run FileSyncCenterActionResult.Rejected( + "The folder sync pair no longer exists.", + ) + if (pair.accountId != NextcloudDocumentIds.accountKey(session)) { + return@run FileSyncCenterActionResult.Rejected( + "This folder sync pair belongs to another account.", + ) + } + withRecoveredFileSyncPairSnapshot( + lock = AndroidFileSyncEngine.ENGINE_LOCK, + snapshot = listOf(pair), + readCurrentSnapshot = { store.load().coordinator.pairs.filter { it.id == pairId } }, + reconcile = { selected -> + reconcileSafDownloadsBeforePairRemoval( + appContext, selected.localRootId, androidSafOwnedDownloadRecoveryPaths(selected), providerRecoverySession = session, + ) + }, + onRecoveryRejected = { FileSyncCenterActionResult.Rejected("A local download still needs safe recovery. Run this folder sync before removing it.") }, + onSnapshotChanged = { FileSyncCenterActionResult.Rejected("Folder sync changed during recovery. Review it before removing it.") }, + ) { + val current = store.load() + val releasesLocalGrant = pair.localRootId.startsWith("content://") && + current.coordinator.pairs.none { it.id != pairId && it.localRootId == pair.localRootId } + var cleanedCoordinator: FileSyncCoordinatorState? = null + var remoteCleanupRejected = false + val removed = removeConfiguredFileSyncPair( + reconcileLocalDownloads = { true }, + cleanRemoteUploads = { + val cleanupResult = cleanupJvmFileSyncOwnedUploads( + androidFileSyncOwnedRemoteTree(session, userId, pair, webDav, context = appContext), + current.coordinator, pairId, fileSyncOwnedUploads(pair), + ) + remoteCleanupRejected = cleanupResult.unresolvedUploads.isNotEmpty() + if (!remoteCleanupRejected) cleanedCoordinator = cleanupResult.state + !remoteCleanupRejected + }, + cleanLedger = { + val mediaStore = createAndroidMediaBackupLedgerStore( + context = appContext, + recoverInterruptedTransfers = false, + ) + try { + mediaStore.deleteUnfinishedSource( + accountId = pair.accountId, + sourceId = pair.id, + legacyLocalKeys = (pair.baselines.asSequence().map(FileSyncBaseline::relativePath) + + pair.workItems.asSequence().map { work -> work.relativePath }) + .distinct() + .map { relativePath -> + legacyMediaBackupLocalKey(pair.localRootId, relativePath) + } + .toList(), + ) + } finally { + mediaStore.close() + } + }, + persistRemoval = { + val remaining = removeFileSyncPair(requireNotNull(cleanedCoordinator), pairId) + store.save( + current.copy( + coordinator = remaining, + localDisplayNames = current.localDisplayNames - pairId, + ), + ) + }, + cancelSchedule = { scheduler.cancel(pairId) }, + releaseLocalGrant = { + releaseSafGrantAfterPairRemoval(appContext, pair.localRootId, releasesLocalGrant) + }, + ) + if (!removed) { + return@withRecoveredFileSyncPairSnapshot FileSyncCenterActionResult.Rejected(if (remoteCleanupRejected) { + "A previous upload still needs safe recovery. Run this folder sync before removing it." + } else "A local download still needs safe recovery. Run this folder sync before removing it.") + } + FileSyncCenterActionResult.Completed("Folder sync pair removed. No local or server files were deleted.") + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt index 81509c2f4..7261abb1b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPicker.kt @@ -10,6 +10,7 @@ import dev.obiente.nextcloudnative.app.FileSyncLocalRoot import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException /** * Single-flight bridge from common suspend APIs to Android's native document-tree picker. @@ -47,11 +48,11 @@ internal class AndroidFileSyncRootPicker(private val context: Context) { } val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val result = runCatching { + requireExternalAndroidPickerUri(uri.toString(), context.applicationContext.packageName) context.contentResolver.takePersistableUriPermission(uri, flags) FileSyncLocalRoot(uri.toString(), queryDisplayName(context.contentResolver, uri)) } - result.onSuccess(continuation::resume) - .onFailure { continuation.cancel(it) } + resumeAndroidFileSyncPickerContinuation(continuation, result) } private fun queryDisplayName(resolver: ContentResolver, treeUri: Uri): String { @@ -68,3 +69,10 @@ internal class AndroidFileSyncRootPicker(private val context: Context) { }.orEmpty().ifBlank { "Selected folder" } } } + +internal fun resumeAndroidFileSyncPickerContinuation( + continuation: CancellableContinuation, + result: Result, +) { + result.fold(continuation::resume, continuation::resumeWithException) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 1ca2f5926..2a0da6fd3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -68,6 +68,7 @@ internal class AndroidLocalUploadPicker(context: Context) { return } val result = runCatching selectionResult@{ + requireExternalAndroidPickerUri(uri.toString(), appContext.packageName) val metadata = resolver.queryUploadMetadata(uri) val mimeType = resolver.getType(uri)?.trim()?.lowercase()?.takeIf(String::isNotBlank) if (!isAcceptedUploadMimeType(mimeType, selection.acceptedMimeTypes)) { @@ -163,9 +164,13 @@ internal class AndroidLocalUploadPicker(context: Context) { } if (cancelledAfterAcquire) return@selectionResult LocalUploadSelectionResult.Cancelled LocalUploadSelectionResult.Selected(file) - }.getOrElse { + }.getOrElse { failure -> LocalUploadSelectionResult.Rejected( - "The selected file could not be opened.", + if (failure is AndroidPickerUriRejectedException) { + failure.rejection.message + } else { + "The selected file could not be opened." + }, ) } resumeLocalUploadSelectionResult( @@ -635,6 +640,14 @@ internal class AndroidLocalUploadPicker(context: Context) { "The persisted local file metadata changed.", ) } + try { + requireExternalAndroidPickerUri(source.uri.toString(), appContext.packageName) + } catch (failure: AndroidPickerUriRejectedException) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The persisted local file provider is not allowed.", + failure, + ) + } requireDurableUploadCapabilityReady(source.phase) return source } @@ -733,24 +746,6 @@ internal class AndroidLocalUploadPicker(context: Context) { } } -private fun requireSafeProcessGeneration(value: String) { - require(value.length in 16..96 && value.all { it.isLetterOrDigit() || it == '-' }) { - "The picker capability process generation is invalid." - } -} - -internal fun resumeLocalUploadSelectionResult( - continuation: CancellableContinuation, - result: LocalUploadSelectionResult, - releaseSelected: (LocalUploadFile) -> Unit, -) { - continuation.resume(result) { _, undeliveredResult, _ -> - if (undeliveredResult is LocalUploadSelectionResult.Selected) { - runCatching { releaseSelected(undeliveredResult.file) } - } - } -} - private data class AndroidUploadMetadata( val displayName: String, val sizeBytes: Long?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPickerPersistence.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPickerPersistence.kt new file mode 100644 index 000000000..2885507f6 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPickerPersistence.kt @@ -0,0 +1,24 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.LocalUploadFile +import dev.obiente.nextcloudnative.app.LocalUploadSelectionResult +import kotlinx.coroutines.CancellableContinuation +import kotlin.coroutines.resume + +internal fun requireSafeProcessGeneration(value: String) { + require(value.length in 16..96 && value.all { it.isLetterOrDigit() || it == '-' }) { + "The picker capability process generation is invalid." + } +} + +internal fun resumeLocalUploadSelectionResult( + continuation: CancellableContinuation, + result: LocalUploadSelectionResult, + releaseSelected: (LocalUploadFile) -> Unit, +) { + continuation.resume(result) { _, undeliveredResult, _ -> + if (undeliveredResult is LocalUploadSelectionResult.Selected) { + runCatching { releaseSelected(undeliveredResult.file) } + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt index f7f44e284..1435494c1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt @@ -9,6 +9,7 @@ internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( cleanupJournal: AndroidAccountRemovalCleanupJournal, suspectEncrypted: String?, prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + revalidateAccountRemoval: suspend (NextcloudSession) -> Unit, removeAccountOwnedState: suspend (NextcloudSession) -> Unit, commitPreferences: (SharedPreferences.Editor) -> Unit, recordCleanupFailure: (Exception) -> Unit, @@ -35,6 +36,7 @@ internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( ) }, prepareAccountRemoval = prepareAccountRemoval, + revalidateAccountRemoval = revalidateAccountRemoval, commitSlotRemoval = { slot, cleanup -> commitPreferences( cleanupJournal.prepareEdit(preferences.edit().remove(slot.preferenceKey), cleanup), @@ -56,6 +58,7 @@ internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( retryPreexistingCleanup: suspend (AndroidIndependentCredentialSlotReset) -> Unit = {}, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + revalidateAccountRemoval: suspend (NextcloudSession) -> Unit = {}, commitSlotRemoval: suspend (AndroidIndependentCredentialSlotReset, AndroidPendingAccountRemovalCleanup) -> Unit, rollbackSlotRemoval: suspend (AndroidIndependentCredentialSlotReset) -> Unit, removeAccountOwnedState: suspend (NextcloudSession) -> Unit, @@ -68,9 +71,13 @@ internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( retryPreexistingCleanup(slot) } val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + withPreparedAndroidAccountRemovalLease( + accountIdentity = NextcloudDocumentIds.accountKey(session), + guard = guard, + prepare = { prepareAccountRemoval(session) }, + revalidate = { revalidateAccountRemoval(session) }, + ) { removeRecoveredAndroidAccountCredentialData( - prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeAccountOwnedState(session) }, clearRecoveredAccount = { commitSlotRemoval(slot, pendingCleanup) }, rollbackRecoveredAccount = { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt index 4ee7ca951..76ecb46fc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt @@ -25,6 +25,9 @@ internal fun createAndroidFileSyncLocalTree( root = resolveMediaStoreSyncRoot(rootId, Environment.getExternalStorageDirectory()), ) } else { + androidFileSyncRootRejection(rootId, appContext.packageName)?.let { rejection -> + throw AndroidPickerUriRejectedException(rejection) + } AndroidSafFileSyncLocalTree( resolver = appContext.contentResolver, rootId = rootId, @@ -33,6 +36,15 @@ internal fun createAndroidFileSyncLocalTree( } } +internal fun androidFileSyncRootRejection( + rootId: String, + applicationId: String, +): AndroidPickerUriRejection? = if (rootId.startsWith(MEDIA_STORE_SYNC_ROOT_PREFIX)) { + null +} else { + androidPickerUriRejection(rootId, applicationId) +} + internal fun createAndroidSafDownloadOwnershipStore( context: Context, treeIdentity: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..7479b2769 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -477,7 +477,6 @@ internal class AndroidNextcloudServices( clearPreviewAccount = nativeMediaPreviewCache::clearAccount, notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, - prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, removeQueuedUploads = accountOwnedStateCleanup::remove, retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, @@ -1354,8 +1353,8 @@ internal class AndroidNextcloudServices( internal suspend fun listFilesWhileAccountLeaseHeld( session: NextcloudSession, userId: String, - path: String, - ): List = listFilesWithSource(session, userId, path, accountLeaseHeld = true).files + path: String, requireNetwork: Boolean = false, + ): List = listFilesWithSource(session, userId, path, accountLeaseHeld = true).filesForProviderRecovery(requireNetwork) private suspend fun listFilesWithSource( session: NextcloudSession, @@ -2367,6 +2366,27 @@ internal class AndroidNextcloudServices( path: String, size: Long, expectedEtag: String, + ): NextcloudFileRangeSession = openFileRangeSession( + session, userId, path, size, expectedEtag, accountLeaseHeld = false, + ) + + internal fun openFileRangeSessionWhileAccountLeaseHeld( + session: NextcloudSession, + userId: String, + path: String, + size: Long, + expectedEtag: String, + ): NextcloudFileRangeSession = openFileRangeSession( + session, userId, path, size, expectedEtag, accountLeaseHeld = true, + ) + + private fun openFileRangeSession( + session: NextcloudSession, + userId: String, + path: String, + size: Long, + expectedEtag: String, + accountLeaseHeld: Boolean, ): NextcloudFileRangeSession { require(size > 0L) { "The file range session size must be positive." } val safeEtag = requireSafeFileRangeEtag(expectedEtag) @@ -2374,7 +2394,10 @@ internal class AndroidNextcloudServices( val authorization = androidFileRangeAuthorization(session) val closed = AtomicBoolean(false) val activity = AndroidFileRangeSessionActivity() - return openTrackedAndroidFileRangeSession(session, { loadSession(session.accountId) }, activity) { + return openTrackedAndroidFileRangeSession( + session, { loadSession(session.accountId) }, activity, + accountLeaseHeld = accountLeaseHeld, + ) { NextcloudFileRangeSession( size = size, readBlock = { offset, length -> @@ -3909,29 +3932,6 @@ internal class AndroidNextcloudServices( } } -internal class AndroidFileRangeUnsupportedException(message: String) : Exception(message) - -internal suspend fun probeSeekableExternalHandoffGeneration( - file: NextcloudFile, - verifyEmptyGeneration: suspend () -> Unit, - openRangeSession: (size: Long, etag: String) -> NextcloudFileRangeSession, -): Boolean { - val size = file.size ?: return false - val etag = file.etag?.takeIf(String::isNotBlank) ?: return false - if (size == 0L) { - verifyEmptyGeneration() - return true - } - val rangeSession = openRangeSession(size, etag) - return try { - rangeSession.read(0L, 1).size == 1 - } catch (_: AndroidFileRangeUnsupportedException) { - false - } finally { - rangeSession.close() - } -} - private fun NextcloudFile.isNativeTiffPreviewFormat(): Boolean { if (isDirectory) return false val extension = name.substringAfterLast('.', missingDelimiterValue = "").lowercase(Locale.ROOT) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerations.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerations.kt new file mode 100644 index 000000000..7a0729d67 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerations.kt @@ -0,0 +1,48 @@ +package dev.obiente.nextcloudnative + +/** Keeps verified content and its remote mutation precondition in the same recovery lifetime. */ +internal class AndroidProviderRecoveryGenerations { + private val verified = mutableMapOf() + private var reading: String? = null + private var readEtag: String? = null + + fun recordReadGeneration(documentId: String, etag: String?) { + check(reading == documentId) { "Recovery read does not match the active verification." } + readEtag = requireNotNull(etag?.takeIf(String::isNotBlank)) { "Recovery read has no remote generation." } + } + + fun mutationEtag(documentId: String, isDirectory: Boolean): String { + require(!isDirectory) { "Directory recovery requires authenticated aggregate generation evidence." } + return requireNotNull(verified[documentId]) { "Recovery mutation has no verified content generation." } + } + + fun run(documentId: String, operation: AndroidDocumentsProviderRecoveryOperation, action: () -> Result): Result { + if (operation == AndroidDocumentsProviderRecoveryOperation.OpenRead) { + check(reading == null) { "Recovery content verification cannot nest." } + verified.remove(documentId) + reading = documentId + readEtag = null + return try { + val result = action() + val etag = requireNotNull(readEtag) { "Recovery content verification did not bind a generation." } + check(verified.size < MAXIMUM_VERIFIED_DOCUMENTS) { "Recovery generation evidence exceeds its bound." } + verified[documentId] = etag + result + } finally { + reading = null + readEtag = null + } + } + val mutation = operation == AndroidDocumentsProviderRecoveryOperation.Rename || + operation == AndroidDocumentsProviderRecoveryOperation.Delete + return try { + action() + } finally { + if (mutation) verified.remove(documentId) + } + } + + private companion object { + const val MAXIMUM_VERIFIED_DOCUMENTS = 4096 + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipStore.kt index 6892af3d3..4068edde0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipStore.kt @@ -25,14 +25,37 @@ internal class AndroidSafDownloadOwnershipStore( ownershipFiles().isNotEmpty() } + fun hasTreeScopedPendingTransactions(): Boolean = synchronized(LOCK) { + ownershipFiles(directory, listFiles).isNotEmpty() + } + + fun pendingTransactions(): List = synchronized(LOCK) { + ownershipRows(includeAll = true).map(StoredOwnershipRow::transaction) + } + + fun legacyPendingTransactions(): List = synchronized(LOCK) { + ownershipRows(files = legacyOwnershipFiles(), includeAll = true).map(StoredOwnershipRow::transaction) + } + + override fun hasPendingTransactionsForDirectory(directoryIdentity: String): Boolean = synchronized(LOCK) { + val scope = scopeDigest(directoryIdentity) + ownershipFiles().any { file -> ownershipReference(file)?.scope == scope } + } + override fun forDirectory(directoryIdentity: String): AndroidSafDownloadOwnership { require(directoryIdentity.isNotBlank()) return ScopedOwnership(scopeDigest(directoryIdentity)) } - fun indexed(): AndroidSafDownloadOwnershipDirectory = synchronized(LOCK) { + fun indexed(allowedTokens: Set? = null): AndroidSafDownloadOwnershipDirectory = synchronized(LOCK) { val files = ownershipFiles() - IndexedOwnershipDirectory(files.mapNotNull(::ownershipReference), files.size) + val references = files.map { file -> + checkNotNull(ownershipReference(file)) { + "SAF download recovery row name is invalid." + } + } + val selected = references.filter { allowedTokens == null || it.token in allowedTokens } + IndexedOwnershipDirectory(selected, selected.size) } private inner class IndexedOwnershipDirectory( @@ -43,6 +66,8 @@ internal class AndroidSafDownloadOwnershipStore( private val referencesByToken = references.associateByTo(mutableMapOf()) { reference -> reference.token } private val rowsByToken = mutableMapOf() private val observedScopesByToken = mutableMapOf>() + private val observedDirectoryIdentitiesByScope = mutableMapOf() + private val observedNamesByScope = mutableMapOf>() init { check(referencesByToken.size == references.size) { @@ -52,16 +77,38 @@ internal class AndroidSafDownloadOwnershipStore( override fun hasPendingTransactions(): Boolean = referencesByToken.isNotEmpty() + override fun hasPendingTransactionsForDirectory(directoryIdentity: String): Boolean = synchronized(LOCK) { + val scope = scopeDigest(directoryIdentity) + IndexedScopedOwnership(scope).transactions(observedNamesByScope[scope].orEmpty()).isNotEmpty() + } + + override fun observedPendingDirectoryIdentities(): Set = synchronized(LOCK) { + requireUnambiguousLocations() + observedDirectoryIdentitiesByScope.mapNotNullTo(linkedSetOf()) { (scope, identity) -> + identity.takeIf { + IndexedScopedOwnership(scope).transactions(observedNamesByScope[scope].orEmpty()).isNotEmpty() + } + } + } + override fun forDirectory(directoryIdentity: String): AndroidSafDownloadOwnership { require(directoryIdentity.isNotBlank()) return IndexedScopedOwnership(scopeDigest(directoryIdentity)) } + private fun requireUnambiguousLocations() { + check(observedScopesByToken.none { (token, scopes) -> token in referencesByToken && scopes.size > 1 }) { + "SAF download recovery has multiple possible locations." + } + } + override fun observeRecoveryNames( directoryIdentity: String, observedNames: Set, ) = synchronized(LOCK) { val scope = scopeDigest(directoryIdentity) + observedDirectoryIdentitiesByScope[scope] = directoryIdentity + observedNamesByScope[scope] = observedNames.toSet() observedRecoveryTokens(observedNames).forEach { token -> observedScopesByToken.getOrPut(token, ::mutableSetOf).add(scope) } @@ -73,20 +120,21 @@ internal class AndroidSafDownloadOwnershipStore( override fun transactions( observedNames: Set, ): List = synchronized(LOCK) { + requireUnambiguousLocations() val tokens = observedRecoveryTokens(observedNames) val references = buildList { referencesByScope[scope].orEmpty().filterTo(this) { reference -> val observedScopes = observedScopesByToken[reference.token].orEmpty() observedScopes.isEmpty() || scope in observedScopes || - !indexedRow(reference).transaction.hasAuthenticatedRelocatedStageEvidence() + !indexedRow(reference).transaction.hasAuthenticatedRelocationEvidence() } tokens.mapNotNullTo(this) { token -> referencesByToken[token] } }.distinctBy { reference -> reference.token } references.map(::indexedRow).filter { row -> row.scope == scope || row.transaction.token in tokens && - row.transaction.hasAuthenticatedRelocatedStageEvidence() + row.transaction.hasAuthenticatedRelocationEvidence() }.map(StoredOwnershipRow::transaction) .sortedWith(compareBy(AndroidSafOwnedDownloadTransaction::finalName).thenBy { it.token }) } @@ -151,8 +199,9 @@ internal class AndroidSafDownloadOwnershipStore( } } - private fun AndroidSafOwnedDownloadTransaction.hasAuthenticatedRelocatedStageEvidence(): Boolean = - stageDocumentIdentity != null && stageContentIdentity != null + private fun AndroidSafOwnedDownloadTransaction.hasAuthenticatedRelocationEvidence(): Boolean = + stageDocumentIdentity != null && stageContentIdentity != null || + backupDocumentIdentity != null && backupContentIdentity != null private inner class ScopedOwnership( private val scope: String, @@ -165,7 +214,7 @@ internal class AndroidSafDownloadOwnershipStore( .filter { row -> row.scope == scope || row.transaction.token in tokens && - row.transaction.hasAuthenticatedRelocatedStageEvidence() + row.transaction.hasAuthenticatedRelocationEvidence() } .map(StoredOwnershipRow::transaction) .sortedWith(compareBy(AndroidSafOwnedDownloadTransaction::finalName).thenBy { it.token }) @@ -240,9 +289,11 @@ internal class AndroidSafDownloadOwnershipStore( private fun ownershipRows( scope: String? = null, tokens: Set = emptySet(), - ): List = ownershipFiles() + files: List = ownershipFiles(), + includeAll: Boolean = false, + ): List = files .mapNotNull(::ownershipReference) - .filter { reference -> reference.scope == scope || reference.token in tokens } + .filter { reference -> includeAll || reference.scope == scope || reference.token in tokens } .map { reference -> val transaction = readRow(reference.file) check(transaction.token == reference.token) { "SAF download recovery row name is invalid." } @@ -251,11 +302,14 @@ internal class AndroidSafDownloadOwnershipStore( private fun ownershipFiles(): List = buildList { addAll(ownershipFiles(directory, listFiles)) - legacyDirectory?.takeIf { it != directory }?.let { legacy -> - addAll(ownershipFiles(legacy, legacy::listFiles)) - } + addAll(legacyOwnershipFiles()) }.distinctBy(File::getAbsolutePath) + private fun legacyOwnershipFiles(): List = legacyDirectory + ?.takeIf { it != directory } + ?.let { legacy -> ownershipFiles(legacy, legacy::listFiles) } + .orEmpty() + private fun ownershipFiles( rowDirectory: File, listing: () -> Array?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadPublication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadPublication.kt index aee8c9eec..bb9e03b5a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadPublication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadPublication.kt @@ -28,6 +28,9 @@ internal interface AndroidSafDownloadOwnership { internal interface AndroidSafDownloadOwnershipDirectory { fun hasPendingTransactions(): Boolean + fun hasPendingTransactionsForDirectory(directoryIdentity: String): Boolean = + forDirectory(directoryIdentity).transactions().isNotEmpty() + fun observedPendingDirectoryIdentities(): Set = emptySet() fun forDirectory(directoryIdentity: String): AndroidSafDownloadOwnership fun observeRecoveryNames(directoryIdentity: String, observedNames: Set) = Unit } @@ -357,7 +360,12 @@ internal class AndroidSafDownloadPublisher( if (transaction.stageDocumentIdentity != null) return transaction val documents = directory.documents() val stage = documents.singleOrNull { it.displayName == transaction.stageName } - ?: recoveryDocuments(transaction, documents).singleOrNull() + ?: recoveryDocuments(transaction, documents).takeIf { + transaction.backupDocumentIdentity == null || + originalDocumentIsFinal(transaction, documents.singleOrNull { it.displayName == transaction.finalName }) + }?.singleOrNull { + transaction.stageContentIdentity != null && contentIdentity(it.document) == transaction.stageContentIdentity + } ?: return transaction return transaction.copy(stageDocumentIdentity = stage.documentIdentity).also(ownership::replace) } @@ -385,6 +393,9 @@ internal class AndroidSafDownloadPublisher( document.displayName != transaction.finalName && (transaction.stageContentIdentity == null || contentIdentity(document.document) == transaction.stageContentIdentity) + } ?: documents.singleOrNull { document -> + document.displayName == transaction.stageName && transaction.stageContentIdentity != null && + contentIdentity(document.document) == transaction.stageContentIdentity } } @@ -408,7 +419,9 @@ internal class AndroidSafDownloadPublisher( document.displayName != transaction.backupName && document.displayName != transaction.generatedBackupName && document.displayName != transaction.stageName && - document.displayName != transaction.finalName + document.displayName != transaction.finalName && + transaction.backupContentIdentity != null && + contentIdentity(document.document) == transaction.backupContentIdentity } } } @@ -645,6 +658,10 @@ internal class AndroidSafDownloadPublisher( stageDocument(transaction) == null && documentNamed(transaction.stageName) == null && recoveryDocuments(transaction).isEmpty() && + directory.documents().none { + transaction.token in it.displayName && it.displayName != transaction.finalName && + !(it.displayName == transaction.backupName && originalDocumentIsFinal(transaction, final)) + } && backupDocument(transaction) == null && (!transaction.backupProtected || transaction.publicationCompleted) ) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafFileSyncPublicationDirectory.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafFileSyncPublicationDirectory.kt new file mode 100644 index 000000000..439cd8b2c --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafFileSyncPublicationDirectory.kt @@ -0,0 +1,46 @@ +package dev.obiente.nextcloudnative + +import android.content.ContentResolver +import android.net.Uri +import android.provider.DocumentsContract +import java.io.OutputStream + +internal class AndroidSafFileSyncPublicationDirectory( + private val resolver: ContentResolver, + private val parentUri: Uri, + private val documents: () -> List>, + private val createDirectory: (String) -> Uri?, + private val writeDocument: (Uri, (OutputStream) -> Unit) -> Unit, + private val providerRecovery: AndroidDocumentsProviderRecoveryAccess, +) : AndroidSafPublicationDirectory { + override fun documents(): List> = documents.invoke() + + override fun createFile(displayName: String): Uri = requireNotNull( + DocumentsContract.createDocument(resolver, parentUri, "application/octet-stream", displayName), + ) { "A staged local file could not be created." } + + override fun createDirectory(displayName: String): Uri = requireNotNull(createDirectory.invoke(displayName)) { + "A staged local folder could not be created." + } + + override fun writeFile(document: Uri, write: (OutputStream) -> Unit) = writeDocument(document, write) + + override fun rename(document: Uri, displayName: String): Uri? = + providerRecovery.run( + document, + AndroidDocumentsProviderRecoveryOperation.Rename, + ) { recoveryUri -> + providerRecovery.normalizeResult( + document, + DocumentsContract.renameDocument(resolver, recoveryUri, displayName), + ) + } + + override fun delete(document: Uri): Boolean = + providerRecovery.run( + document, + AndroidDocumentsProviderRecoveryOperation.Delete, + ) { recoveryUri -> + DocumentsContract.deleteDocument(resolver, recoveryUri) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafOwnedDownloadRetirement.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafOwnedDownloadRetirement.kt new file mode 100644 index 000000000..b73e331b0 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSafOwnedDownloadRetirement.kt @@ -0,0 +1,267 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CancellationException + +internal data class AndroidSafOwnedDownloadRecoveryDirectory( + val documentId: String, + val relativePath: String, +) + +internal fun androidSafOwnedDownloadRecoveryPaths(pair: FileSyncPair): Set = + (pair.baselines.asSequence().map { baseline -> baseline.relativePath } + + pair.workItems.asSequence().map { work -> work.relativePath }) + .toSet() + +internal fun androidSafOwnedDownloadRecoveryDirectories( + rootDocumentId: String, + localRecoveryPaths: Set, + recordedDocumentIds: Set = emptySet(), +): List { + val root = NextcloudDocumentIds.parse(rootDocumentId) + return buildSet { + add("") + localRecoveryPaths.mapTo(this, NextcloudDocumentIds::parentPath) + recordedDocumentIds.forEach { documentId -> + val reference = runCatching { NextcloudDocumentIds.parse(documentId) }.getOrNull() + ?: return@forEach + if (reference.accountKey != root.accountKey) return@forEach + val parentPath = NextcloudDocumentIds.parentPath(reference.path) + val relativePath = when { + root.path.isEmpty() -> parentPath + parentPath == root.path -> "" + parentPath.startsWith(root.path + "/") -> parentPath.removePrefix(root.path + "/") + else -> return@forEach + } + add(relativePath) + } + }.map { relativePath -> + val fullPath = listOf(root.path, relativePath).filter(String::isNotBlank).joinToString("/") + AndroidSafOwnedDownloadRecoveryDirectory( + documentId = NextcloudDocumentIds.documentId(root.accountKey, fullPath), + relativePath = relativePath, + ) + } +} + +internal fun androidSafOwnedDownloadRecoveryDirectory( + rootDocumentId: String, + directoryDocumentId: String, +): AndroidSafOwnedDownloadRecoveryDirectory? { + val root = runCatching { NextcloudDocumentIds.parse(rootDocumentId) }.getOrNull() ?: return null + val directory = runCatching { NextcloudDocumentIds.parse(directoryDocumentId) }.getOrNull() ?: return null + if (directory.accountKey != root.accountKey) return null + val relativePath = when { + root.path.isEmpty() -> directory.path + directory.path == root.path -> "" + directory.path.startsWith(root.path + "/") -> directory.path.removePrefix(root.path + "/") + else -> return null + } + return AndroidSafOwnedDownloadRecoveryDirectory(directoryDocumentId, relativePath) +} + +internal fun reconcileRecordedAndroidSafDownloadDirectories( + candidates: List, + hasPendingRecovery: () -> Boolean, + hasPendingForDirectory: (Directory) -> Boolean, + shouldContinue: () -> Boolean = { true }, + reconcileDirectory: (Directory) -> Unit, +): Boolean { + if (!hasPendingRecovery()) return true + candidates.distinct().forEach { candidate -> + requireAndroidSafRetirementContinuation(shouldContinue) + if (!hasPendingForDirectory(candidate)) return@forEach + requireAndroidSafRetirementContinuation(shouldContinue) + reconcileDirectory(candidate) + } + requireAndroidSafRetirementContinuation(shouldContinue) + return !hasPendingRecovery() +} + +internal fun reconcileRecordedThenDiscoveredAndroidSafDownloadDirectories( + recordedCandidates: List, + discoverCandidates: () -> List, + hasPendingRecovery: () -> Boolean, + hasPendingForDirectory: (Directory) -> Boolean, + shouldContinue: () -> Boolean = { true }, + reconcileDirectory: (Directory) -> Unit, +): Boolean { + if ( + reconcileRecordedAndroidSafDownloadDirectories( + candidates = recordedCandidates, + hasPendingRecovery = hasPendingRecovery, + hasPendingForDirectory = hasPendingForDirectory, + shouldContinue = shouldContinue, + reconcileDirectory = { candidate -> + try { + reconcileDirectory(candidate) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + // A recorded document ID may be stale after the recovery directory was moved. + } + }, + ) + ) return true + return reconcileRecordedAndroidSafDownloadDirectories( + candidates = discoverCandidates(), + hasPendingRecovery = hasPendingRecovery, + hasPendingForDirectory = hasPendingForDirectory, + shouldContinue = shouldContinue, + reconcileDirectory = reconcileDirectory, + ) +} + +internal fun requireAndroidSafRetirementContinuation(shouldContinue: () -> Boolean) { + if (!shouldContinue()) throw CancellationException("Folder sync recovery was cancelled.") +} + +internal fun hasRelevantAndroidSafOwnedDownloadRecovery( + treeScopedPending: Boolean, + legacyTransactions: List, + identityBelongsToTree: (String) -> Boolean?, +): Boolean = treeScopedPending || legacyTransactions.any { transaction -> + !androidSafOwnedDownloadIsProvenUnrelatedToTree(transaction, identityBelongsToTree) +} + +internal fun androidSafOwnedDownloadIsProvenUnrelatedToTree( + transaction: AndroidSafOwnedDownloadTransaction, + identityBelongsToTree: (String) -> Boolean?, +): Boolean { + val identities = listOfNotNull(transaction.stageDocumentIdentity, transaction.backupDocumentIdentity) + if (identities.isEmpty()) return false + val memberships = identities.map { identity -> identityBelongsToTree(identity) ?: return false } + return memberships.none { it } +} + +internal fun reconcileOwnProviderSafDownloadsBeforePairRemoval( + context: Context, + localRootId: String, + localRecoveryPaths: Set, + shouldContinue: () -> Boolean, + providerRecoverySession: NextcloudSession?, +) { + val tree = Uri.parse(localRootId) + val root = DocumentsContract.getTreeDocumentId(tree) + if (androidRootBoundProviderRecoverySession(root, providerRecoverySession) != null) { + reconcileBoundProviderSafDownloads(context, localRootId, localRecoveryPaths, shouldContinue, providerRecoverySession, false) + } else { + val services = AndroidNextcloudServices(context.applicationContext) + withAndroidCrossAccountProviderRecovery(root, tree.authority == nextcloudDocumentsAuthority(context.packageName), services::loadSession) { session -> + reconcileBoundProviderSafDownloads(context, localRootId, localRecoveryPaths, shouldContinue, session, true) + } + } +} + +private fun reconcileBoundProviderSafDownloads( + context: Context, + localRootId: String, + localRecoveryPaths: Set, + shouldContinue: () -> Boolean, + providerRecoverySession: NextcloudSession?, + preserveTreeGrant: Boolean, +) { + val appContext = context.applicationContext + val treeUri = Uri.parse(localRootId) + val discoveryRootId = androidSafRetirementDiscoveryRoot( + DocumentsContract.getTreeDocumentId(treeUri), providerRecoverySession.takeUnless { preserveTreeGrant }, + ) + val ownership = createAndroidSafDownloadOwnershipStore(appContext, localRootId) + val localTree = AndroidSafFileSyncLocalTree( + resolver = appContext.contentResolver, + rootId = localRootId, + downloadOwnershipStore = ownership, + providerRecoverySession = providerRecoverySession, + localRecoveryAuthority = nextcloudDocumentsAuthority(appContext.packageName), + preserveProviderTreeGrant = preserveTreeGrant, + ) + val recordedDocumentIds = ownership.pendingTransactions().asSequence() + .flatMap { transaction -> + sequenceOf(transaction.stageDocumentIdentity, transaction.backupDocumentIdentity) + } + .filterNotNull() + .mapNotNull { identity -> + runCatching { + identity.takeIf { + androidPickerUriRejection(it, appContext.packageName) == + AndroidPickerUriRejection.OwnDocumentsProvider + }?.let { DocumentsContract.getDocumentId(Uri.parse(it)) } + }.getOrNull() + } + .toSet() + val recordedCandidates = androidSafOwnedDownloadRecoveryDirectories( + rootDocumentId = DocumentsContract.getTreeDocumentId(treeUri), + localRecoveryPaths = localRecoveryPaths, + recordedDocumentIds = recordedDocumentIds, + ).map { candidate -> + candidate to DocumentsContract.buildDocumentUriUsingTree(treeUri, candidate.documentId) + } + val identityBelongsToTree: (String) -> Boolean? = identity@{ identity -> + if ( + androidPickerUriRejection(identity, appContext.packageName) != + AndroidPickerUriRejection.OwnDocumentsProvider + ) return@identity null + val documentId = runCatching { DocumentsContract.getDocumentId(Uri.parse(identity)) }.getOrNull() + ?: return@identity null + androidSafOwnedDownloadRecoveryDirectory( + DocumentsContract.getTreeDocumentId(treeUri), + documentId, + ) != null + } + val legacyTokens = ownership.legacyPendingTransactions().mapTo(hashSetOf()) { it.token } + val relevantTokens = ownership.pendingTransactions().filter { transaction -> + transaction.token !in legacyTokens || + !androidSafOwnedDownloadIsProvenUnrelatedToTree(transaction, identityBelongsToTree) + }.mapTo(hashSetOf()) { it.token } + val indexedOwnership = ownership.indexed(relevantTokens) + val hasRelevantPendingRecovery = { + hasRelevantAndroidSafOwnedDownloadRecovery( + treeScopedPending = ownership.hasTreeScopedPendingTransactions(), + legacyTransactions = ownership.legacyPendingTransactions(), + identityBelongsToTree = identityBelongsToTree, + ) + } + check( + reconcileRecordedThenDiscoveredAndroidSafDownloadDirectories( + recordedCandidates = recordedCandidates, + discoverCandidates = { + localTree.indexRecoveryLocationsIfNeeded( + indexedOwnership, shouldContinue, + DocumentsContract.buildDocumentUriUsingTree(treeUri, discoveryRootId), + ) + indexedOwnership.observedPendingDirectoryIdentities().mapNotNull { identity -> + val directoryUri = runCatching { Uri.parse(identity) }.getOrNull() + ?: return@mapNotNull null + val documentId = runCatching { DocumentsContract.getDocumentId(directoryUri) }.getOrNull() + ?: return@mapNotNull null + androidSafOwnedDownloadRecoveryDirectory( + rootDocumentId = discoveryRootId, + directoryDocumentId = documentId, + )?.let { candidate -> candidate to directoryUri } + } + }, + hasPendingRecovery = hasRelevantPendingRecovery, + hasPendingForDirectory = { (_, directoryUri) -> + indexedOwnership.hasPendingTransactionsForDirectory(directoryUri.toString()) + }, + shouldContinue = shouldContinue, + reconcileDirectory = { (candidate, directoryUri) -> + localTree.downloadPublisher( + parentUri = directoryUri, + parentPath = candidate.relativePath, + shouldContinue = shouldContinue, + ownershipDirectory = indexedOwnership, + ).reconcileForSync() + }, + ), + ) { "A local download still needs safe recovery." } +} + +/** Expand token discovery only when this process owns the account-bound recovery session. */ +internal fun androidSafRetirementDiscoveryRoot(rootDocumentId: String, session: NextcloudSession?): String = + androidRootBoundProviderRecoverySession(rootDocumentId, session)?.let(NextcloudDocumentIds::rootId) + ?: rootDocumentId diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index a2d4d5799..25acb8ff4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -39,10 +39,14 @@ internal object NextcloudDocumentIds { return "$PREFIX:$accountKey:" } - fun documentId(session: NextcloudSession, path: String): String { + fun documentId(session: NextcloudSession, path: String): String = + documentId(accountKey(session), path) + + fun documentId(accountKey: String, path: String): String { + require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } val normalizedPath = normalizePath(path) val encodedPath = encoder.encodeToString(normalizedPath.encodeToByteArray()) - return "$PREFIX:${accountKey(session)}:$encodedPath" + return "$PREFIX:$accountKey:$encodedPath" } fun parse(documentId: String): NextcloudDocumentReference { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContract.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContract.kt index 0e75e8c86..e4136755b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContract.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContract.kt @@ -1,5 +1,8 @@ package dev.obiente.nextcloudnative +import java.net.URI +import java.net.URISyntaxException + internal const val NEXTCLOUD_DOCUMENTS_AUTHORITY_SUFFIX = ".documents" /** Matches the manifest's `${applicationId}.documents` authority for every build variant. */ @@ -7,3 +10,58 @@ internal fun nextcloudDocumentsAuthority(applicationId: String): String { require(applicationId.isNotBlank()) { "The application ID must not be blank." } return applicationId + NEXTCLOUD_DOCUMENTS_AUTHORITY_SUFFIX } + +internal enum class AndroidPickerUriRejection(val message: String) { + OwnDocumentsProvider("Files from nati.ve cannot be selected here."), + Invalid("The selected file provider returned an invalid URI."), +} + +internal class AndroidPickerUriRejectedException( + val rejection: AndroidPickerUriRejection, +) : IllegalArgumentException(rejection.message) + +internal fun requireExternalAndroidPickerUri( + uri: String, + applicationId: String, +): Unit { + androidPickerUriRejection(uri, applicationId)?.let { rejection -> + throw AndroidPickerUriRejectedException(rejection) + } +} + +internal fun androidPickerUriRejection( + uri: String, + applicationId: String, +): AndroidPickerUriRejection? { + val parsed = try { + URI(uri) + } catch (_: URISyntaxException) { + return AndroidPickerUriRejection.Invalid + } + if (!parsed.scheme.equals("content", ignoreCase = true)) { + return AndroidPickerUriRejection.Invalid + } + val authority = parsed.authority?.takeIf(String::isNotBlank) + ?: return AndroidPickerUriRejection.Invalid + if (authority.any { character -> + character.isWhitespace() || character.isISOControl() || character in ":/\\?#" + } + ) { + return AndroidPickerUriRejection.Invalid + } + val userSeparator = authority.indexOf('@') + val providerAuthority = when { + userSeparator < 0 -> authority + userSeparator != authority.lastIndexOf('@') -> return AndroidPickerUriRejection.Invalid + userSeparator == 0 -> return AndroidPickerUriRejection.Invalid + authority.take(userSeparator).any { character -> !character.isDigit() } -> + return AndroidPickerUriRejection.Invalid + else -> authority.substring(userSeparator + 1).takeIf(String::isNotBlank) + ?: return AndroidPickerUriRejection.Invalid + } + return if (providerAuthority.equals(nextcloudDocumentsAuthority(applicationId), ignoreCase = true)) { + AndroidPickerUriRejection.OwnDocumentsProvider + } else { + null + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 7cbb908a3..99e4417be 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -101,7 +101,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { override fun queryDocument(documentId: String, projection: Array?): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() + val session = requireAndroidDocumentsProviderQuerySession(documentId, services::loadSession) if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { val handoff = AndroidExternalFileHandoffRegistry.peek(documentId, session) ?: throw FileNotFoundException("This external file handoff has expired.") @@ -125,20 +125,15 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() + val (session, recoveryAuthorized) = requireAndroidDocumentsProviderChildrenSession(parentDocumentId, services::loadSession) val parent = requireReference(parentDocumentId, session) - val children = runCatching { - val account = resolveAccount(session) - runBlocking(Dispatchers.IO) { services.listFiles(session, account.userId, parent.path) } - }.getOrElse { failure -> - val cachedChildren = offline.availableChildren(session, parent.path) - if (cachedChildren.isNotEmpty() || offline.isStoredDirectory(session, parent.path)) { - cachedChildren - } else { - throw FileNotFoundException("Could not load this Nextcloud folder.").also { - it.initCause(failure) - } - } + val children = runBlocking(Dispatchers.IO) { + loadAndroidProviderChildren(recoveryAuthorized, read = { + val account = resolveAccount(session) + if (recoveryAuthorized) services.listFilesWhileAccountLeaseHeld(session, account.userId, parent.path, requireNetwork = true) + else services.listFilesWithSource(session, account.userId, parent.path).filesForProviderRecovery(recoveryAuthorized) + }, cached = { offline.availableChildren(session, parent.path) }, + storedDirectory = { offline.isStoredDirectory(session, parent.path) }) } children.forEach { cursor.addDocumentRow(session, it) } return cursor @@ -186,7 +181,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } signal?.throwIfCanceled() - val session = requireSession() + val (session, recoveryAuthorized) = requireAndroidDocumentsProviderOpenSession(documentId, mode, services::loadSession) if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { if (mode != "r") throw SecurityException("External file handoffs are read-only.") return openExternalHandoffDocument(session, documentId, signal) @@ -194,16 +189,16 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val reference = requireReference(documentId, session) if (reference.isRoot) throw FileNotFoundException("Folders cannot be opened as files.") if (mode == "r") { - offline.availableContent(session, reference.path)?.let { cached -> + readAndroidUnversionedProviderContent(recoveryAuthorized) { offline.availableContent(session, reference.path) }?.let { cached -> signal?.throwIfCanceled() return ParcelFileDescriptor.open(cached.content, ParcelFileDescriptor.MODE_READ_ONLY) } } val account = resolveAccount(session) - val file = runCatching { findDocument(session, account, reference.path) } + val file = runCatching { findDocument(session, account, reference.path, recoveryAuthorized) } .getOrElse { failure -> if (mode == "r") { - virtualFiles.acquire(session, reference.path)?.let { lease -> + readAndroidUnversionedProviderContent(recoveryAuthorized) { virtualFiles.acquire(session, reference.path) }?.let { lease -> signal?.throwIfCanceled() return openVirtualFileLease(lease) } @@ -212,7 +207,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") if (mode != "r") return openWritableDocument(session, account, file, mode, signal) - + recordAndroidProviderRecoveryReadGeneration(documentId, file.etag) file.etag?.takeIf(String::isNotBlank)?.let { etag -> virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> signal?.throwIfCanceled() @@ -220,14 +215,14 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } - return openVirtualFileProxy(session, account.userId, file, signal) + return openVirtualFileProxy(session, account.userId, file, signal, recoveryAuthorized) } private fun openVirtualFileProxy( session: NextcloudSession, userId: String, file: NextcloudFile, - signal: CancellationSignal?, + signal: CancellationSignal?, accountLeaseHeld: Boolean, ): ParcelFileDescriptor { val size = file.size ?: throw FileNotFoundException( "Nextcloud did not provide a file size for seekable access.", @@ -247,12 +242,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { virtualFiles.discardHydrationStagingFile(empty) } } - val rangeSession = services.openFileRangeSession( + val rangeSession = services.openDocumentProviderFileRangeSession( session = session, userId = userId, path = file.path, - size = size, - expectedEtag = etag, + size = size, expectedEtag = etag, accountLeaseHeld = accountLeaseHeld, ) val staging = try { virtualFiles.prepareHydration(session, size) @@ -463,7 +457,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + withAndroidDocumentsProviderCreate(parentDocumentId, services::loadSession) { session -> val parent = requireReference(parentDocumentId, session) val account = resolveAccount(session) requireAndroidDocumentDirectory(parent) { findDocument(session, account, it, accountLeaseHeld = true) } @@ -483,14 +477,14 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } override fun renameDocument(documentId: String, displayName: String): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + withAndroidDocumentsProviderRename(documentId, services::loadSession) { session -> val reference = requireReference(documentId, session) if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") val account = resolveAccount(session) val file = findDocument(session, account, reference.path, accountLeaseHeld = true) val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) - if (destination == reference.path) return@withAndroidDocumentMutation documentId - val etag = requireMutationEtag(file) + if (destination == reference.path) return@withAndroidDocumentsProviderRename documentId + val etag = androidProviderRecoveryMutationEtag(documentId, requireMutationEtag(file), file.isDirectory) withNoBlockingAndroidDocumentWriteback(context, session, reference.path, destination) { mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } } @@ -499,7 +493,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } override fun deleteDocument(documentId: String) = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + withAndroidDocumentsProviderDelete(documentId, services::loadSession) { session -> val reference = requireReference(documentId, session) if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") val account = resolveAccount(session) @@ -510,7 +504,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { session, account.userId, reference.path, - requireMutationEtag(file), + androidProviderRecoveryMutationEtag(documentId, requireMutationEtag(file), file.isDirectory), isDirectory = file.isDirectory, ) } @@ -523,7 +517,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { sourceParentDocumentId: String, targetParentDocumentId: String, ): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + withAndroidDocumentsProviderMove(sourceDocumentId, services::loadSession) { session -> val source = requireReference(sourceDocumentId, session) val sourceParent = requireReference(sourceParentDocumentId, session) val targetParent = requireReference(targetParentDocumentId, session) @@ -535,7 +529,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { requireAndroidDocumentDirectory(targetParent) { findDocument(session, account, it, accountLeaseHeld = true) } val file = findDocument(session, account, source.path, accountLeaseHeld = true) val destination = childPath(targetParent.path, file.name) - if (destination == source.path) return@withAndroidDocumentMutation sourceDocumentId + if (destination == source.path) return@withAndroidDocumentsProviderMove sourceDocumentId withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { mutationCall { webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) @@ -860,10 +854,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } private fun findDocument( - session: NextcloudSession, - account: ResolvedAccount, - path: String, - accountLeaseHeld: Boolean = false, + session: NextcloudSession, account: ResolvedAccount, path: String, accountLeaseHeld: Boolean = false, requireNetwork: Boolean = accountLeaseHeld, ): NextcloudFile = providerCall( message = "The requested Nextcloud document was not found.", @@ -871,8 +862,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) { val parent = NextcloudDocumentIds.parentPath(path) runBlocking(Dispatchers.IO) { - if (accountLeaseHeld) services.listFilesWhileAccountLeaseHeld(session, account.userId, parent) - else services.listFiles(session, account.userId, parent) + if (accountLeaseHeld) services.listFilesWhileAccountLeaseHeld(session, account.userId, parent, requireNetwork = true) + else services.listFilesWithSource(session, account.userId, parent).filesForProviderRecovery(requireNetwork) }.firstOrNull { it.path == path } ?: throw FileNotFoundException("The requested Nextcloud document was not found.") } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 3f2fc3801..9701cd8f7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -3,6 +3,8 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -236,7 +238,8 @@ class AndroidAccountOperationGuardTest { revokeAndroidSessionWithAccountLease( accountIdentity = "account-a", guard = guard, - preflight = {}, + prepare = {}, + revalidate = {}, revoke = { remoteRevoked.complete(Unit) }, removeLocalAccount = { allowLocalRemoval.await() @@ -454,6 +457,58 @@ class AndroidAccountOperationGuardTest { withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { } } + @Test + fun recoveryRangeSessionUsesHeldAccountLeaseWhileOrdinaryReadsStillValidateSession() = runBlocking { + val guard = AndroidAccountOperationGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + var sourceOpened = false + val executor = Executors.newSingleThreadExecutor() + + try { + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + val read = executor.submit { + runBlocking { + val rangeSession = openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + coordinator = coordinator, + accountLeaseHeld = true, + openSource = { + sourceOpened = true + NextcloudFileRangeSession(8L, { _, length -> ByteArray(length) }) + }, + ) + try { + rangeSession.read(0L, 1).size == 1 + } finally { + rangeSession.close() + } + } + } + assertTrue(read.get(1, TimeUnit.SECONDS)) + + } + assertFailsWith { + openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session.copy(appPassword = "replacement-password") }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + coordinator = coordinator, + accountLeaseHeld = false, + openSource = { error("stale ordinary source must not open") }, + ) + } + } finally { + executor.shutdownNow() + } + + assertTrue(sourceOpened) + } + @Test fun sameAccountReauthenticationDrainsOldPasswordRangeBeforeCredentialCommit() = runBlocking { val coordinator = AndroidFileRangeSessionCoordinator() @@ -536,6 +591,111 @@ class AndroidAccountOperationGuardTest { assertEquals(replacement, current) } + @Test + fun removalPreparationCanUseTheRetainedReadLeaseBeforeRemovalBecomesExclusive() = runBlocking { + val guard = AndroidAccountOperationGuard() + val accountIdentity = "account-a" + val events = mutableListOf() + + withTimeout(1_000L) { + withPreparedAndroidAccountRemovalLease( + accountIdentity = accountIdentity, + guard = guard, + prepare = { + guard.withAccount(accountIdentity) { events += "provider-read" } + }, + revalidate = { events += "revalidate" }, + ) { + events += "remove" + } + } + + assertEquals(listOf("provider-read", "revalidate", "remove"), events) + } + + @Test + fun unavailableRemovalUsesOnlyCredentialFreePreflight() = runBlocking { + val guard = AndroidAccountOperationGuard() + val events = mutableListOf() + + withUnavailableAndroidAccountRemovalLease( + accountIdentity = "account-a", + guard = guard, + preflight = { events += "preflight" }, + ) { + events += "remove" + } + + assertEquals(listOf("preflight", "preflight", "remove"), events) + } + + @Test + fun accountWorkStartedAfterPreparationMakesRemovalFailClosed() = runBlocking { + val guard = AndroidAccountOperationGuard() + val accountIdentity = "account-a" + var removalEntered = false + var competingLease: AndroidAccountOperationLease? = null + + val failure = try { + assertFailsWith { + withTimeout(1_000L) { + withPreparedAndroidAccountRemovalLease( + accountIdentity = accountIdentity, + guard = guard, + prepare = { + competingLease = guard.acquireBlocking(accountIdentity) + }, + revalidate = {}, + ) { + removalEntered = true + } + } + } + } finally { + competingLease?.close() + } + + assertEquals( + "Finish or discard pending document changes before removing this account.", + failure.message, + ) + assertFalse(removalEntered) + } + + @Test + fun removalStateIsRevalidatedAfterTheAccountLeaseIsAcquired() = runBlocking { + val guard = AndroidAccountOperationGuard() + val accountIdentity = "account-a" + var removalReady = true + var removalEntered = false + var revalidationHeldLease = false + + val failure = assertFailsWith { + withPreparedAndroidAccountRemovalLease( + accountIdentity = accountIdentity, + guard = guard, + prepare = { removalReady = false }, + revalidate = { + revalidationHeldLease = guard.tryWithAccount( + accountIdentity, + unavailable = { true }, + action = { false }, + ) + check(removalReady) { "Account state changed after preparation." } + }, + ) { + removalEntered = true + } + } + + assertEquals("Account state changed after preparation.", failure.message) + assertTrue(revalidationHeldLease) + assertFalse(removalEntered) + withTimeout(1_000L) { + guard.withAccount(accountIdentity) { } + } + } + @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecoveryTest.kt new file mode 100644 index 000000000..0f17987f4 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidCrossAccountProviderRecoveryTest.kt @@ -0,0 +1,48 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class AndroidCrossAccountProviderRecoveryTest { + private val session = NextcloudSession("https://cloud.example.test", "reader", "synthetic") + private val root = NextcloudDocumentIds.rootId(session) + + @Test fun busyRootAccountFailsWithoutWaitingOrRunningRecovery(): Unit = runBlocking { + val guard = AndroidAccountOperationGuard() + var ran = false + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + assertFailsWith { + withAndroidCrossAccountProviderRecovery(root, true, { session }, guard) { ran = true } + } + } + assertFalse(ran) + assertEquals(session, withAndroidCrossAccountProviderRecovery(root, true, { session }, guard) { it }) + } + + @Test fun missingMismatchedAndCrossProfileAccountsCannotAuthorizeRecovery() { + assertFailsWith { withAndroidCrossAccountProviderRecovery(root, false, { session }) { kotlin.test.fail("Unexpected recovery") } } + assertFailsWith { withAndroidCrossAccountProviderRecovery(root, true, { null }) { kotlin.test.fail("Unexpected recovery") } } + assertFailsWith { + withAndroidCrossAccountProviderRecovery(root, true, { session.copy(loginName = "other") }) { kotlin.test.fail("Unexpected recovery") } + } + var reads = 0 + assertFailsWith { + withAndroidCrossAccountProviderRecovery(root, true, { if (reads++ == 0) session else session.copy(appPassword = "new") }) { + kotlin.test.fail("Unexpected recovery") + } + } + } + + @Test fun cancellationReleasesTheRootAccountLease() { + val guard = AndroidAccountOperationGuard() + assertFailsWith { + withAndroidCrossAccountProviderRecovery(root, true, { session }, guard) { throw CancellationException("synthetic") } + } + assertEquals(session, withAndroidCrossAccountProviderRecovery(root, true, { session }, guard) { it }) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderManifestTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderManifestTest.kt new file mode 100644 index 000000000..1afcd8199 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentsProviderManifestTest.kt @@ -0,0 +1,40 @@ +package dev.obiente.nextcloudnative + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AndroidDocumentsProviderManifestTest { + @Test + fun `documents provider stays in the application process for thread confined recovery`() { + val manifest = parseXml(androidMainSourceDirectory().resolve("AndroidManifest.xml")) + val providers = manifest.getElementsByTagName("provider") + val documentsProvider = (0 until providers.length) + .map(providers::item) + .firstOrNull { provider -> + provider.attributes.getNamedItemNS(ANDROID_XML_NAMESPACE, "name")?.nodeValue == + ".NextcloudDocumentsProvider" + } + + assertNotNull(documentsProvider) + assertNull(documentsProvider.attributes.getNamedItemNS(ANDROID_XML_NAMESPACE, "process")) + } + + private fun androidMainSourceDirectory(): File { + val workingDirectory = File(requireNotNull(System.getProperty("user.dir"))) + return listOf(workingDirectory.resolve("src/main"), workingDirectory.resolve("androidApp/src/main")) + .firstOrNull { candidate -> candidate.resolve("AndroidManifest.xml").isFile } + ?: error("Could not locate the Android main source directory.") + } + + private fun parseXml(file: File) = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + }.newDocumentBuilder().parse(file) + + private companion object { + const val ANDROID_XML_NAMESPACE = "http://schemas.android.com/apk/res/android" + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt index 56d138995..c71a8ea6c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt @@ -12,8 +12,23 @@ import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout class AndroidFileReadCacheTest { + @Test + fun providerRecoveryUsesTheAccountLeaseHeldByItsCaller() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + withTimeout(1_000L) { + withRetainedAndroidAccountFileRead( + session, { session }, guard, accountLeaseHeld = true, + ) {} + } + } + } + @Test fun listingMetadataSurvivesProcessRestartWithFullDavIdentity() = withCache { root, cache -> val file = file("Notes/vault.md", "\"etag-1\"").copy( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecoveryTest.kt new file mode 100644 index 000000000..c566616e5 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncPairRecoveryTest.kt @@ -0,0 +1,87 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.test.assertFailsWith +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout + +class AndroidFileSyncPairRecoveryTest { + private val pair = FileSyncPair( + id = "pair", accountId = "account-a", localRootId = "content://example.documents/tree/root", + remoteRootPath = "Documents", configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + + @Test + fun providerRecoveryDoesNotHoldEngineWhileWaitingForAnotherAccount() = runBlocking { + val engine = Mutex() + val otherAccount = Mutex(locked = true) + val providerEntered = CompletableDeferred() + val recovery = async { + withRecoveredFileSyncPairSnapshot( + engine, listOf(pair), { listOf(pair) }, + reconcile = { + assertFalse(engine.isLocked) + providerEntered.complete(Unit) + otherAccount.withLock { true } + }, + onRecoveryRejected = { "rejected" }, onSnapshotChanged = { "changed" }, + commit = { assertTrue(engine.isLocked); "committed" }, + ) + } + withTimeout(5_000) { + providerEntered.await() + // The other account can finish its engine work and release its lease. + engine.withLock { otherAccount.unlock() } + assertEquals("committed", recovery.await()) + } + } + + @Test + fun changedPairSnapshotCannotCommitAfterProviderRecovery() = runBlocking { + val engine = Mutex() + var current = listOf(pair) + var committed = false + val outcome = withRecoveredFileSyncPairSnapshot( + engine, current, { current }, + reconcile = { current = listOf(pair.copy(remoteRootPath = "Changed")); true }, + onRecoveryRejected = { "rejected" }, onSnapshotChanged = { "changed" }, + commit = { committed = true; "committed" }, + ) + assertEquals("changed", outcome) + assertFalse(committed) + } + + @Test + fun rejectedRecoveryPreservesThePairAndDoesNotCommit() = runBlocking { + var committed = false + val outcome = withRecoveredFileSyncPairSnapshot( + Mutex(), listOf(pair), { listOf(pair) }, { false }, + onRecoveryRejected = { "rejected" }, onSnapshotChanged = { "changed" }, + commit = { committed = true; "committed" }, + ) + assertEquals("rejected", outcome) + assertFalse(committed) + } + + @Test + fun cancellationCannotCommitRemoval() = runBlocking { + var committed = false + assertFailsWith { + withRecoveredFileSyncPairSnapshot( + Mutex(), listOf(pair), { listOf(pair) }, { throw CancellationException() }, + onRecoveryRejected = {}, onSnapshotChanged = {}, commit = { committed = true }, + ) + } + assertFalse(committed) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncProviderFeedbackRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncProviderFeedbackRecoveryTest.kt new file mode 100644 index 000000000..d59b08e5f --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncProviderFeedbackRecoveryTest.kt @@ -0,0 +1,283 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncBaseline +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.SyncEntryKind +import java.io.FileNotFoundException +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout + +class AndroidFileSyncProviderFeedbackRecoveryTest { + private val applicationId = "dev.obiente.nextcloudnative.dev" + private val ownAuthority = nextcloudDocumentsAuthority(applicationId) + private val accountKey = "0123456789abcdef0123456789abcdef" + + @Test + fun `credential removal fences account work before waiting for the sync engine`() = runBlocking { + val session = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + val guard = AndroidAccountOperationGuard() + val engineLock = Mutex(locked = true) + val waitingForEngine = CompletableDeferred() + val recovery = async { + withAndroidFileSyncAccountRecoveryLease(session, { session }, guard) { + waitingForEngine.complete(Unit) + engineLock.withLock {} + } + } + waitingForEngine.await() + + val competingWorkEntered = guard.tryWithAccount( + NextcloudDocumentIds.accountKey(session), unavailable = { false }, action = { true }, + ) + assertFalse(competingWorkEntered) + + engineLock.unlock() + withTimeout(1_000L) { recovery.await() } + } + + @Test + fun `restored own provider root stops before remote preparation`() { + var remoteCalls = 0 + val rejection = androidFileSyncRootRejection( + "content://$ownAuthority/tree/${NextcloudDocumentIds.rootId(accountKey)}", + applicationId, + ) + if (rejection == null) remoteCalls += 1 + + assertEquals(AndroidPickerUriRejection.OwnDocumentsProvider, rejection) + assertEquals(0, remoteCalls) + } + + @Test + fun `legacy own provider recovery visits only recorded parent and allows removal`() = runBlocking { + val pair = FileSyncPair( + id = "pair", + accountId = accountKey, + localRootId = "content://$ownAuthority/tree/${NextcloudDocumentIds.rootId(accountKey)}", + remoteRootPath = "Mirror", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + baselines = listOf( + FileSyncBaseline("Archive/kept.txt", SyncEntryKind.File, "local", "remote"), + ), + ) + val recordedStageId = NextcloudDocumentIds.documentId( + accountKey, + "Pending/.nextcloud-native-download-123e4567-e89b-12d3-a456-426614174000", + ) + val candidates = androidSafOwnedDownloadRecoveryDirectories( + NextcloudDocumentIds.rootId(accountKey), + androidSafOwnedDownloadRecoveryPaths(pair), + setOf(recordedStageId), + ) + val pendingDocumentIds = mutableSetOf(NextcloudDocumentIds.documentId(accountKey, "Pending")) + val events = mutableListOf() + + val removed = removeConfiguredFileSyncPair( + reconcileLocalDownloads = { + reconcileRecordedAndroidSafDownloadDirectories( + candidates = candidates, + hasPendingRecovery = pendingDocumentIds::isNotEmpty, + hasPendingForDirectory = { candidate -> candidate.documentId in pendingDocumentIds }, + reconcileDirectory = { candidate -> + events += "reconcile:${candidate.relativePath}" + pendingDocumentIds -= candidate.documentId + }, + ) + }, + cleanRemoteUploads = { events += "remote-cleanup"; true }, + cleanLedger = { events += "ledger-cleanup" }, + persistRemoval = { events += "persist-removal" }, + cancelSchedule = { events += "cancel-schedule" }, + releaseLocalGrant = { events += "release-grant" }, + ) + + assertTrue(removed) + assertEquals( + listOf( + "reconcile:Pending", + "remote-cleanup", + "ledger-cleanup", + "persist-removal", + "cancel-schedule", + "release-grant", + ), + events, + ) + } + + @Test + fun `account removal recovers target downloads before credential deletion`() = runBlocking { + val retained = pair("retained", "other-account") + val first = pair("first", accountKey) + val second = pair("second", accountKey) + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { + reconcileConfiguredFileSyncAccountDownloadsBeforeCredentialRemoval( + pairs = listOf(retained, first, second), + accountId = accountKey, + reconcileLocalDownloads = { pair -> + events += "recover:${pair.id}" + true + }, + ) + }, + removeQueuedUploads = { events += "remove-owned-state" }, + clearActiveAccount = { events += "delete-credential" }, + rollbackActiveRemoval = {}, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) + + assertEquals( + listOf("recover:first", "recover:second", "delete-credential", "remove-owned-state"), + events, + ) + } + + @Test + fun `account removal blocks unclassified ownership evidence`() { + val root = Files.createTempDirectory("saf-provider-removal-invalid-row-").toFile() + try { + val invalid = root.resolve("unclassified.row").apply { writeBytes(byteArrayOf(0x01)) } + val store = AndroidSafDownloadOwnershipStore(root) + + val removalReady = reconcileSafDownloadsBeforePairRemoval( + hasPersistedGrant = true, + hasPendingRecovery = store.hasPendingTransactions(), + reconcile = { store.indexed() }, + ) + + assertFalse(removalReady) + assertTrue(invalid.isFile) + assertTrue(store.hasPendingTransactions()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `cancelled own provider retirement stops before the next directory`() { + val events = mutableListOf() + var continuationChecks = 0 + + assertFailsWith { + reconcileRecordedAndroidSafDownloadDirectories( + candidates = listOf("first", "second"), + hasPendingRecovery = { true }, + hasPendingForDirectory = { + events += "check:$it" + true + }, + shouldContinue = { + continuationChecks += 1 + continuationChecks < 3 + }, + reconcileDirectory = { events += "reconcile:$it" }, + ) + } + + assertEquals(listOf("check:first", "reconcile:first"), events) + } + + @Test + fun `recorded recovery completes before full tree discovery`() { + var pending = true + var discoveryCalls = 0 + + val reconciled = reconcileRecordedThenDiscoveredAndroidSafDownloadDirectories( + recordedCandidates = listOf("recorded"), + discoverCandidates = { + discoveryCalls += 1 + error("The unrelated tree is not inspectable") + }, + hasPendingRecovery = { pending }, + hasPendingForDirectory = { it == "recorded" && pending }, + reconcileDirectory = { pending = false }, + ) + + assertTrue(reconciled) + assertEquals(0, discoveryCalls) + } + + @Test + fun `unresolved recorded recovery discovers a relocated directory`() { + var pending = true + val events = mutableListOf() + + val reconciled = reconcileRecordedThenDiscoveredAndroidSafDownloadDirectories( + recordedCandidates = listOf("recorded"), + discoverCandidates = { + events += "discover" + listOf("relocated") + }, + hasPendingRecovery = { pending }, + hasPendingForDirectory = { it == "relocated" && pending }, + reconcileDirectory = { + events += "reconcile:$it" + pending = false + }, + ) + + assertTrue(reconciled) + assertEquals(listOf("discover", "reconcile:relocated"), events) + } + + @Test + fun `missing recorded directory falls through to relocated discovery`() { + var pending = true + val events = mutableListOf() + + val reconciled = reconcileRecordedThenDiscoveredAndroidSafDownloadDirectories( + recordedCandidates = listOf("stale"), + discoverCandidates = { + events += "discover" + listOf("relocated") + }, + hasPendingRecovery = { pending }, + hasPendingForDirectory = { pending }, + reconcileDirectory = { candidate -> + events += "reconcile:$candidate" + if (candidate == "stale") throw FileNotFoundException("The directory moved") + pending = false + }, + ) + + assertTrue(reconciled) + assertEquals(listOf("reconcile:stale", "discover", "reconcile:relocated"), events) + } + + @Test + fun `relocated recovery directory keeps its exact relative path`() { + val root = NextcloudDocumentIds.documentId(accountKey, "Sync") + val relocated = NextcloudDocumentIds.documentId(accountKey, "Sync/Moved/Parent") + + assertEquals( + AndroidSafOwnedDownloadRecoveryDirectory(relocated, "Moved/Parent"), + androidSafOwnedDownloadRecoveryDirectory(root, relocated), + ) + } + + private fun pair(id: String, owner: String) = FileSyncPair( + id = id, + accountId = owner, + localRootId = "content://external/tree/$id", + remoteRootPath = id, + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPickerTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPickerTest.kt new file mode 100644 index 000000000..ce5a946a3 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRootPickerTest.kt @@ -0,0 +1,25 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine + +class AndroidFileSyncRootPickerTest { + @Test + fun `provider rejection remains a typed failure instead of coroutine cancellation`() { + val rejection = AndroidPickerUriRejectedException(AndroidPickerUriRejection.OwnDocumentsProvider) + + val thrown = assertFailsWith { + runBlocking { + suspendCancellableCoroutine { continuation -> + resumeAndroidFileSyncPickerContinuation(continuation, Result.failure(rejection)) + } + } + } + + assertEquals(AndroidPickerUriRejection.OwnDocumentsProvider, thrown.rejection) + assertEquals(AndroidPickerUriRejection.OwnDocumentsProvider.message, thrown.message) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt index 40ee694de..0bcebb695 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt @@ -8,6 +8,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout class AndroidIndependentCredentialSlotResetTest { @Test @@ -66,28 +67,39 @@ class AndroidIndependentCredentialSlotResetTest { val events = mutableListOf() val presentSlots = mutableSetOf(first.preferenceKey, second.preferenceKey) val tombstones = mutableSetOf() + val guard = AndroidAccountOperationGuard() - retireUnregisteredAndroidAccountCredentialSlots( - slots = listOf(first, second), - guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = { session -> events += "prepare-${session.loginName}" }, - commitSlotRemoval = { slot, cleanup -> - events += "commit-${slot.session.loginName}" - presentSlots -= slot.preferenceKey - tombstones += cleanup.accountStorageKey - }, - rollbackSlotRemoval = { slot -> presentSlots += slot.preferenceKey }, - removeAccountOwnedState = { session -> - assertFalse(androidAccountCredentialSlotKey(session.accountId) in presentSlots) - assertTrue(session.accountId.storageKey in tombstones) - events += "cleanup-${session.loginName}" - }, - clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, - recordCleanupFailure = { error("cleanup must succeed") }, - ) + withTimeout(1_000L) { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = guard, + prepareAccountRemoval = { session -> + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + events += "prepare-${session.loginName}" + } + }, + revalidateAccountRemoval = { session -> events += "revalidate-${session.loginName}" }, + commitSlotRemoval = { slot, cleanup -> + events += "commit-${slot.session.loginName}" + presentSlots -= slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { slot -> presentSlots += slot.preferenceKey }, + removeAccountOwnedState = { session -> + assertFalse(androidAccountCredentialSlotKey(session.accountId) in presentSlots) + assertTrue(session.accountId.storageKey in tombstones) + events += "cleanup-${session.loginName}" + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = { error("cleanup must succeed") }, + ) + } assertEquals( - listOf("prepare-alice", "commit-alice", "cleanup-alice", "prepare-bob", "commit-bob", "cleanup-bob"), + listOf( + "prepare-alice", "revalidate-alice", "commit-alice", "cleanup-alice", + "prepare-bob", "revalidate-bob", "commit-bob", "cleanup-bob", + ), events, ) assertTrue(presentSlots.isEmpty()) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPickerProviderFeedbackTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPickerProviderFeedbackTest.kt new file mode 100644 index 000000000..a7f12d0cc --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPickerProviderFeedbackTest.kt @@ -0,0 +1,73 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AndroidPickerProviderFeedbackTest { + private val applicationId = "dev.obiente.nextcloudnative.dev" + private val ownAuthority = nextcloudDocumentsAuthority(applicationId) + + @Test + fun `own document and tree uris are rejected before grant or capability publication`() { + val sideEffects = mutableListOf() + val ownUris = listOf( + "content://$ownAuthority/document/nc2%3Aaccount%3Aincarnation%3Afile", + "content://$ownAuthority/tree/root/document/root%2Ffolder", + "content://${ownAuthority.uppercase()}/document/file", + "content://10@$ownAuthority/tree/root", + "content://dev%2Eobiente%2Enextcloudnative%2Edev%2Edocuments/document/file", + ) + + ownUris.forEach { uri -> + val failure = assertFailsWith { + requireExternalAndroidPickerUri(uri, applicationId) + sideEffects += "take-grant" + sideEffects += "publish-capability" + } + assertEquals(AndroidPickerUriRejection.OwnDocumentsProvider, failure.rejection) + } + + assertEquals(emptyList(), sideEffects) + } + + @Test + fun `malformed picker uris fail before durable state`() { + val sideEffects = mutableListOf() + val malformedUris = listOf( + "file://$ownAuthority/document/file", + "content:///document/file", + "content://user@external.documents/document/file", + "content://10@@external.documents/document/file", + "content://external%2Fdocuments/document/file", + "content://external.documents/%broken", + ) + + malformedUris.forEach { uri -> + val failure = assertFailsWith { + requireExternalAndroidPickerUri(uri, applicationId) + sideEffects += "create-durable-state" + } + assertEquals(AndroidPickerUriRejection.Invalid, failure.rejection) + } + + assertEquals(emptyList(), sideEffects) + } + + @Test + fun `unrelated external document and tree providers remain accepted`() { + val accepted = mutableListOf() + val externalUris = listOf( + "content://com.android.providers.downloads.documents/document/42", + "content://EXTERNAL.PROVIDER/tree/root/document/root%2Ffolder", + "content://10@external_provider/tree/root", + ) + + externalUris.forEach { uri -> + requireExternalAndroidPickerUri(uri, applicationId) + accepted += uri + } + + assertEquals(externalUris, accepted) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerationsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerationsTest.kt new file mode 100644 index 000000000..e646d310e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoveryGenerationsTest.kt @@ -0,0 +1,76 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AndroidProviderRecoveryGenerationsTest { + @Test + fun exactConsumedPermitCarriesTheVerifiedGenerationIntoProviderMutation() { + val session = dev.obiente.nextcloudnative.app.NextcloudSession("https://cloud.example.test", "alice", "synthetic") + val id = NextcloudDocumentIds.documentId(session, "recovery-file") + val generations = AndroidProviderRecoveryGenerations() + withAndroidDocumentsProviderRecoveryPermit(session, id, AndroidDocumentsProviderRecoveryOperation.OpenRead, generations) { + resolveAndroidDocumentsProviderSession(id, AndroidDocumentsProviderRecoveryOperation.OpenRead, true) { null } + recordAndroidProviderRecoveryReadGeneration(id, "generation-a") + } + withAndroidDocumentsProviderRecoveryPermit(session, id, AndroidDocumentsProviderRecoveryOperation.Delete, generations) { + resolveAndroidDocumentsProviderSession(id, AndroidDocumentsProviderRecoveryOperation.Delete, true) { null } + assertEquals("generation-a", androidProviderRecoveryMutationEtag(id, "generation-b", false)) + } + assertEquals("generation-b", androidProviderRecoveryMutationEtag(id, "generation-b", false)) + } + + @Test + fun replacementAfterHashCannotChangeTheAuthenticatedMutationPrecondition() { + for (mutation in listOf(AndroidDocumentsProviderRecoveryOperation.Rename, AndroidDocumentsProviderRecoveryOperation.Delete)) { + val generations = AndroidProviderRecoveryGenerations() + var serverEtag = "generation-a" + generations.run("file", AndroidDocumentsProviderRecoveryOperation.OpenRead) { + generations.recordReadGeneration("file", serverEtag) + "authenticated original bytes" + } + serverEtag = "generation-b" + var preserved = true + generations.run("file", mutation) { + val condition = generations.mutationEtag("file", isDirectory = false) + assertEquals("generation-a", condition) + if (condition == serverEtag) preserved = false + } + assertEquals(true, preserved) + assertFailsWith { generations.mutationEtag("file", false) } + } + } + + @Test + fun failedOrCancelledReverificationInvalidatesEarlierProof() { + for (failure in listOf(IllegalStateException("read failed"), CancellationException("cancelled"))) { + val generations = AndroidProviderRecoveryGenerations() + generations.run("file", AndroidDocumentsProviderRecoveryOperation.OpenRead) { + generations.recordReadGeneration("file", "old") + } + assertFailsWith { + generations.run("file", AndroidDocumentsProviderRecoveryOperation.OpenRead) { + generations.recordReadGeneration("file", "new") + throw failure + } + } + assertFailsWith { generations.mutationEtag("file", false) } + } + } + + @Test + fun absentAndCrossDocumentProofNeverAuthorizeMutationOrDirectoryRecovery() { + val generations = AndroidProviderRecoveryGenerations() + assertFailsWith { + generations.run("file", AndroidDocumentsProviderRecoveryOperation.OpenRead) { "unbound bytes" } + } + generations.run("file", AndroidDocumentsProviderRecoveryOperation.OpenRead) { + generations.recordReadGeneration("file", "generation-a") + } + assertFailsWith { generations.mutationEtag("other", false) } + assertFailsWith { generations.mutationEtag("file", true) } + assertEquals("generation-a", generations.mutationEtag("file", false)) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoverySafetyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoverySafetyTest.kt new file mode 100644 index 000000000..ff4bd8191 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidProviderRecoverySafetyTest.kt @@ -0,0 +1,118 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession + +import dev.obiente.nextcloudnative.app.NextcloudFileListing +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class AndroidProviderRecoverySafetyTest { + @Test + fun recoveryNeverAuthenticatesAnUnversionedOfflineOrFailureFallbackCopy() { + for (cacheKind in listOf("offline pin", "virtual fallback after failed lookup")) { + var openedStaleCopy = false + val recovered = readAndroidUnversionedProviderContent(recoveryAuthorized = true) { + openedStaleCopy = true + "old content from $cacheKind" + } + assertNull(recovered) + kotlin.test.assertFalse(openedStaleCopy) + assertEquals("ordinary cached content", readAndroidUnversionedProviderContent(false) { "ordinary cached content" }) + } + } + + @Test + fun relocatedOwnedFilesAreDiscoverableOutsideTheOldSubtreeOnlyWithBoundAccountAccess() { + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val oldRoot = NextcloudDocumentIds.documentId(session, "original/subtree") + val movedDirectory = NextcloudDocumentIds.documentId(session, "elsewhere/moved") + val discoveryRoot = androidSafRetirementDiscoveryRoot(oldRoot, session) + kotlin.test.assertEquals(NextcloudDocumentIds.rootId(session), discoveryRoot) + kotlin.test.assertNotNull(androidSafOwnedDownloadRecoveryDirectory(discoveryRoot, movedDirectory)) + kotlin.test.assertNull(androidSafOwnedDownloadRecoveryDirectory(oldRoot, movedDirectory)) + kotlin.test.assertEquals(oldRoot, androidSafRetirementDiscoveryRoot(oldRoot, null)) + kotlin.test.assertEquals(oldRoot, androidSafRetirementDiscoveryRoot(oldRoot, session.copy(loginName = "bob"))) + kotlin.test.assertNull(androidSafOwnedDownloadRecoveryDirectory( + discoveryRoot, NextcloudDocumentIds.documentId(session.copy(loginName = "bob"), "elsewhere/moved"), + )) + } + + @Test + fun legacyTreeRecoveryBindsOnlyTheAccountOwningItsDocument() { + val removing = NextcloudSession("https://cloud.example.test", "alice", "password") + val other = removing.copy(loginName = "bob") + kotlin.test.assertEquals(removing, androidRootBoundProviderRecoverySession(NextcloudDocumentIds.rootId(removing), removing)) + kotlin.test.assertNull(androidRootBoundProviderRecoverySession(NextcloudDocumentIds.rootId(other), removing)) + kotlin.test.assertNull(androidRootBoundProviderRecoverySession(NextcloudDocumentIds.rootId(other), null)) + kotlin.test.assertFailsWith { + androidRootBoundProviderRecoverySession("invalid", removing) + } + } + + @Test + fun boundRecoveryRangeReadsUseTheSuppliedSessionBeforeCredentialPersistence() = runBlocking { + val session = dev.obiente.nextcloudnative.app.NextcloudSession("https://cloud.example.test", "alice", "synthetic") + val source = openTrackedAndroidFileRangeSession( + session, { error("Deferred recovery cannot require a persisted credential") }, AndroidFileRangeSessionActivity(), + accountLeaseHeld = true, + ) { dev.obiente.nextcloudnative.app.NextcloudFileRangeSession(1, { _, _ -> byteArrayOf(7) }, {}) } + try { + kotlin.test.assertContentEquals(byteArrayOf(7), source.read(0, 1)) + } finally { source.close() } + } + + @Test + fun pendingRetirementPreservesTheGrantNeededForItsNextRecoveryAttempt() = runBlocking { + var revoked = false + assertFailsWith { + retireAndroidFileSyncBeforeGrantRevocation({ error("pending local transaction") }, { revoked = true }) + } + kotlin.test.assertFalse(revoked) + retireAndroidFileSyncBeforeGrantRevocation({}, { revoked = true }) + kotlin.test.assertTrue(revoked) + } + + @Test + fun recoveryNeverTreatsCachedAbsenceAsAuthoritative() = runBlocking { + val cached = NextcloudFileListing(emptyList(), NextcloudFileListingSource.Cache) + assertFailsWith { + loadAndroidProviderChildren(true, { cached.filesForProviderRecovery(true) }, + { error("Recovery must not use offline entries") }, { error("Recovery must not use stored directories") }) + } + assertEquals(emptyList(), loadAndroidProviderChildren(false, { error("synthetic offline") }, { emptyList() }, { true })) + assertEquals(emptyList(), NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network).filesForProviderRecovery(true)) + } + + @Test + fun crossProfileRecoveryUsesTheVerifiedLocalProviderAuthority() { + val authority = "dev.example.documents" + assertEquals(authority, androidLocalRecoveryAuthority("10@$authority", authority)) + assertEquals(authority, androidLocalRecoveryAuthority(authority, authority)) + listOf("10@other.documents", "x@$authority", "10@20@$authority").forEach { + assertFailsWith { androidLocalRecoveryAuthority(it, authority) } + } + } + + @Test + fun aLateRangeRegistrationRemainsVisibleToFinalQuiesce() = runBlocking { + val coordinator = AndroidFileRangeSessionCoordinator() + val old = AndroidFileRangeSessionActivity() + val cancelled = CompletableDeferred() + val finish = checkNotNull(old.start { cancelled.complete(Unit) }) + coordinator.register("synthetic-account", old, old::close) + val firstQuiesce = async { coordinator.quiesce("synthetic-account") } + cancelled.await() + val late = AndroidFileRangeSessionActivity() + coordinator.register("synthetic-account", late, late::close) + finish() + firstQuiesce.await() + coordinator.quiesce("synthetic-account") + assertNull(late.start()) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipIndexTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipIndexTest.kt index 21b0a5522..e6fbc7912 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipIndexTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadOwnershipIndexTest.kt @@ -9,6 +9,93 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidSafDownloadOwnershipIndexTest { + @Test + fun `relocated backup-only recovery requires both identity and content evidence`() { + val root = Files.createTempDirectory("saf-relocated-backup-").toFile() + try { + val store = AndroidSafDownloadOwnershipStore(root) + val backup = AndroidSafOwnedDownloadTransaction( + finalName = "Removed.txt", token = FIRST_TOKEN, + backupDocumentIdentity = "content://provider/document/old-backup", + backupContentIdentity = "sha256:${"a".repeat(64)}", + ) + val original = "content://provider/document/original" + val moved = "content://provider/document/moved" + val names = setOf("provider-backup-${backup.token}") + val stage = authenticatedRelocationTransaction() + val cases = listOf( + backup.copy(backupContentIdentity = null) to false, + backup.copy(backupDocumentIdentity = null) to false, + stage.copy(stageContentIdentity = null) to false, + stage.copy(stageDocumentIdentity = null) to false, + backup to true, + stage to true, + stage.copy(backupDocumentIdentity = backup.backupDocumentIdentity, backupContentIdentity = backup.backupContentIdentity) to true, + ) + for ((transaction, attributable) in cases) { + store.forDirectory(original).add(transaction) + val index = store.indexed() + index.observeRecoveryNames(moved, names) + val matches = index.forDirectory(moved).transactions(names) + assertEquals(if (attributable) listOf(transaction) else emptyList(), matches) + store.forDirectory(original).remove(transaction) + } + store.forDirectory(original).add(backup) + val ambiguous = store.indexed() + ambiguous.observeRecoveryNames(moved, names) + ambiguous.observeRecoveryNames("content://provider/document/copied", names) + assertFailsWith { ambiguous.observedPendingDirectoryIdentities() } + assertEquals(listOf(backup), store.pendingTransactions()) + } finally { root.deleteRecursively() } + } + + @Test + fun `copied recovery tokens preserve ownership and reject every candidate`() { + val root = Files.createTempDirectory("saf-ambiguous-recovery-").toFile() + try { + val store = AndroidSafDownloadOwnershipStore(root) + val owned = authenticatedRelocationTransaction() + store.forDirectory("content://provider/document/original").add(owned) + val index = store.indexed() + val names = setOf("provider-stage-${owned.token}") + val candidates = listOf("content://provider/document/moved", "content://provider/document/copied") + candidates.forEach { index.observeRecoveryNames(it, names) } + assertFailsWith { index.observedPendingDirectoryIdentities() } + candidates.forEach { candidate -> + assertFailsWith { index.forDirectory(candidate).transactions(names) } + } + assertEquals(listOf(owned), store.pendingTransactions()) + assertTrue(index.hasPendingTransactions()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `expanded discovery cannot reconcile another tree's legacy transaction`() { + val root = Files.createTempDirectory("saf-scoped-recovery-tokens-").toFile() + try { + val store = AndroidSafDownloadOwnershipStore(root) + val owned = authenticatedRelocationTransaction() + val unrelated = owned.copy(token = SECOND_TOKEN, finalName = "Unrelated.txt") + val original = "content://provider/document/original" + val relocated = "content://provider/document/elsewhere" + store.forDirectory(original).add(owned) + store.forDirectory(relocated).add(unrelated) + val index = store.indexed(setOf(owned.token)) + val names = setOf("provider-stage-${owned.token}", "provider-stage-${unrelated.token}") + index.observeRecoveryNames(relocated, names) + index.observeRecoveryNames("content://provider/document/unrelated-copy", setOf("provider-stage-${unrelated.token}")) + index.observeRecoveryNames(relocated, names) + assertEquals(listOf(owned), index.forDirectory(relocated).transactions(names)) + index.forDirectory(relocated).remove(owned) + assertFalse(index.hasPendingTransactions()) + assertEquals(listOf(unrelated), store.pendingTransactions()) + } finally { + root.deleteRecursively() + } + } + @Test fun `pending ownership is isolated from unrelated SAF trees`() { val base = Files.createTempDirectory("saf-download-tree-index-").toFile() @@ -66,6 +153,57 @@ class AndroidSafDownloadOwnershipIndexTest { } } + @Test + fun `selected tree ignores only legacy ownership proven to belong elsewhere`() { + val unrelated = AndroidSafOwnedDownloadTransaction( + "Elsewhere.txt", + FIRST_TOKEN, + stageDocumentIdentity = "document:other-tree", + ) + val unclassified = AndroidSafOwnedDownloadTransaction("Unknown.txt", SECOND_TOKEN) + + assertFalse( + hasRelevantAndroidSafOwnedDownloadRecovery( + treeScopedPending = false, + legacyTransactions = listOf(unrelated), + identityBelongsToTree = { false }, + ), + ) + assertTrue( + hasRelevantAndroidSafOwnedDownloadRecovery( + treeScopedPending = false, + legacyTransactions = listOf(unclassified), + identityBelongsToTree = { null }, + ), + ) + assertTrue( + hasRelevantAndroidSafOwnedDownloadRecovery( + treeScopedPending = true, + legacyTransactions = listOf(unrelated), + identityBelongsToTree = { false }, + ), + ) + } + + @Test + fun `selected tree reads legacy and tree scoped ownership separately`() { + val base = Files.createTempDirectory("saf-download-selected-tree-").toFile() + try { + val legacy = AndroidSafDownloadOwnershipStore(base) + val selected = androidSafDownloadOwnershipStoreForTree(base, "content://provider/tree/selected") + val legacyTransaction = AndroidSafOwnedDownloadTransaction("Legacy.txt", FIRST_TOKEN) + val selectedTransaction = AndroidSafOwnedDownloadTransaction("Selected.txt", SECOND_TOKEN) + legacy.forDirectory("content://provider/tree/other/document/parent").add(legacyTransaction) + selected.forDirectory("content://provider/tree/selected/document/parent").add(selectedTransaction) + + assertEquals(listOf(legacyTransaction), selected.legacyPendingTransactions()) + assertEquals(listOf(selectedTransaction, legacyTransaction), selected.pendingTransactions()) + assertTrue(selected.hasTreeScopedPendingTransactions()) + } finally { + base.deleteRecursively() + } + } + @Test fun `tree-wide recovery indexing is skipped without pending ownership`() { val root = Files.createTempDirectory("saf-download-empty-index-").toFile() @@ -206,6 +344,7 @@ class AndroidSafDownloadOwnershipIndexTest { index.observeRecoveryNames(relocatedScope, setOf(relocatedName)) assertEquals(emptyList(), index.forDirectory(originalScope).transactions()) + assertEquals(setOf(relocatedScope), index.observedPendingDirectoryIdentities()) assertEquals( listOf(transaction), index.forDirectory(relocatedScope).transactions(setOf(relocatedName)), @@ -215,6 +354,37 @@ class AndroidSafDownloadOwnershipIndexTest { } } + @Test + fun `indexed directory membership does not relist ownership rows`() { + val root = Files.createTempDirectory("saf-download-ownership-index-membership-").toFile() + try { + val pendingScope = "content://provider/tree/root/document/pending" + val store = AndroidSafDownloadOwnershipStore(root) + store.forDirectory(pendingScope).add(authenticatedRelocationTransaction()) + var listingCount = 0 + val indexed = AndroidSafDownloadOwnershipStore( + directory = root, + listFiles = { + listingCount += 1 + root.listFiles() + }, + ).indexed() + + repeat(20_000) { candidate -> + assertEquals( + candidate == 17, + indexed.hasPendingTransactionsForDirectory( + if (candidate == 17) pendingScope else "content://provider/tree/root/document/$candidate", + ), + ) + } + + assertEquals(1, listingCount) + } finally { + root.deleteRecursively() + } + } + @Test fun `token-only document in another directory cannot relocate pending ownership`() { val root = Files.createTempDirectory("saf-download-ownership-token-collision-").toFile() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadStageIdentityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadStageIdentityTest.kt index 91116a1b4..e8e3d3892 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadStageIdentityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafDownloadStageIdentityTest.kt @@ -148,7 +148,7 @@ class AndroidSafDownloadStageIdentityTest { } @Test - fun `restart adopts a normalized stage created before identity persistence`() { + fun `restart preserves a normalized stage without persisted identity or content proof`() { val transaction = AndroidSafOwnedDownloadTransaction("Report.txt", TOKEN) val normalizedStageName = "provider-stage-$TOKEN" val directory = FakeSafDirectory().apply { @@ -174,14 +174,11 @@ class AndroidSafDownloadStageIdentityTest { publisher(directory).reconcile() - val adopted = directory.ownership.transactions().single() - assertEquals(directory.documentNamed(normalizedStageName).toString(), adopted.stageDocumentIdentity) - assertEquals(listOf("Report.txt"), publisher(directory).visibleDocuments().map { it.displayName }) - - publisher(directory).reconcile() - - assertEquals(listOf("Report.txt"), directory.names()) - assertEquals(emptyList(), directory.ownership.transactions()) + assertEquals(listOf(pending), directory.ownership.transactions()) + assertEquals(setOf("Report.txt", normalizedStageName), directory.names().toSet()) + assertContentEquals(byteArrayOf(10, 11), directory.entryNamed("Report.txt").bytes) + assertEquals(0, directory.deleteCalls) + assertFailsWith { publisher(directory).reconcileForSync() } } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafRelocatedBackupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafRelocatedBackupRecoveryTest.kt new file mode 100644 index 000000000..ee027a32e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidSafRelocatedBackupRecoveryTest.kt @@ -0,0 +1,71 @@ +package dev.obiente.nextcloudnative + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class AndroidSafRelocatedBackupRecoveryTest { + @Test fun relocatedDeletionBackupRestoresOnlyItsAuthenticatedContent() { + for ((changed, alternateName) in listOf(false to false, true to false, false to true, true to true)) { + val root = Files.createTempDirectory("relocated-delete-backup-").toFile() + try { + val directory = FakeSafDirectory() + val initial = AndroidSafOwnedDownloadTransaction("Archive", "01234567-89ab-cdef-0123-456789abcdef") + val original = directory.addFile(initial.backupName, byteArrayOf(1, 2)) + val content = directory.contentIdentity(original) + val backupName = if (alternateName) "provider-backup-${initial.token}" else initial.backupName + if (changed) { + directory.delete(original) + directory.addFile(initial.backupName, byteArrayOf(8, 9)) + } + if (alternateName) directory.rename(directory.documentNamed(initial.backupName), backupName) + val transaction = initial.copy( + backupProtected = true, backupDocumentIdentity = "old-provider-identity", + backupContentIdentity = content, + ) + val store = AndroidSafDownloadOwnershipStore(root) + store.forDirectory("content://provider/document/original").add(transaction) + val index = store.indexed() + val relocated = "content://provider/document/moved" + index.observeRecoveryNames(relocated, setOf(backupName)) + val publisher = AndroidSafDownloadPublisher(directory, index.forDirectory(relocated), { initial.token }, directory::contentIdentity) + if (changed) { + assertFailsWith { publisher.reconcileForSync() } + assertEquals(listOf(transaction), store.pendingTransactions()) + assertContentEquals(byteArrayOf(8, 9), directory.entryNamed(backupName).bytes) + } else { + publisher.reconcile() + assertEquals(emptyList(), store.pendingTransactions()) + assertContentEquals(byteArrayOf(1, 2), directory.entryNamed("Archive").bytes) + } + } finally { root.deleteRecursively() } + } + } + + @Test fun movedStagesRequireContentProofAndUnknownRenamedCandidatesStayPending() { + for ((changed, alternateName) in listOf(false to false, true to false, false to true, true to true)) { + val directory = FakeSafDirectory() + val initial = AndroidSafOwnedDownloadTransaction("Archive", "01234567-89ab-cdef-0123-456789abcdef") + val original = directory.addFile(initial.stageName, byteArrayOf(1, 2)) + val content = directory.contentIdentity(original) + directory.delete(original) + val name = if (alternateName) "renamed-stage-${initial.token}" else initial.stageName + val bytes = if (changed) byteArrayOf(8, 9) else byteArrayOf(1, 2) + directory.addFile(name, bytes) + val transaction = initial.copy(stageDocumentIdentity = original.toString(), stageContentIdentity = content) + directory.ownership.add(transaction) + val publisher = AndroidSafDownloadPublisher(directory, directory.ownership, { initial.token }, directory::contentIdentity) + if (!changed && !alternateName) { + publisher.reconcileForSync() + assertEquals(emptyList(), directory.ownership.transactions()) + assertEquals(emptyList(), directory.names()) + } else { + assertFailsWith { publisher.reconcileForSync() } + assertEquals(listOf(transaction), directory.ownership.transactions()) + assertContentEquals(bytes, directory.entryNamed(name).bytes) + } + } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index b0d6c7465..0a0304273 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -1,10 +1,14 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlin.concurrent.thread import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -55,6 +59,315 @@ class NextcloudDocumentsContractTest { ) } + @Test + fun `recovery permit resolves only its inactive target operation`() { + val active = session("active", "active-secret") + val removed = session("removed", "removed-secret") + val removedDocument = NextcloudDocumentIds.documentId(removed, "Sync/report.txt") + val activeDocument = NextcloudDocumentIds.documentId(active, "Documents/current.txt") + + withAndroidDocumentsProviderRecoveryPermit( + removed, + removedDocument, + AndroidDocumentsProviderRecoveryOperation.QueryChildren, + ) { + assertEquals( + removed, + resolveSession(removedDocument, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { active } + ?.session, + ) + assertEquals( + active, + resolveSession(activeDocument, AndroidDocumentsProviderRecoveryOperation.QueryDocument) { active } + ?.session, + ) + assertEquals( + null, + resolveSession(removedDocument, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { active }, + ) + } + + assertEquals( + null, + resolveSession(removedDocument, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { active }, + ) + } + + @Test + fun `recovery permit is confined to its synchronous dispatch thread`() { + val unavailable = session("unavailable", "") + val documentId = NextcloudDocumentIds.documentId(unavailable, "Sync") + val resolved = AtomicReference() + + withAndroidDocumentsProviderRecoveryPermit( + unavailable, + documentId, + AndroidDocumentsProviderRecoveryOperation.QueryChildren, + ) { + val dispatch = thread(start = true) { + resolved.set( + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { null } + ?.session, + ) + } + dispatch.join() + assertEquals( + unavailable, + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { null } + ?.session, + ) + } + + assertEquals(null, resolved.get()) + } + + @Test + fun `external provider caller cannot consume an inactive recovery permit`() { + val active = session("active", "active-secret") + val removed = session("removed", "removed-secret") + val removedDocument = NextcloudDocumentIds.documentId(removed, "Sync") + + withAndroidDocumentsProviderRecoveryPermit( + removed, + removedDocument, + AndroidDocumentsProviderRecoveryOperation.Delete, + ) { + assertEquals( + null, + resolveSession( + removedDocument, + AndroidDocumentsProviderRecoveryOperation.Delete, + allowRecoveryPermit = false, + ) { active }, + ) + assertEquals( + removed, + resolveSession(removedDocument, AndroidDocumentsProviderRecoveryOperation.Delete) { active }?.session, + ) + } + } + + @Test + fun `paused recovery rejects unrelated mutation and writable open`() { + val active = session("active", "active-secret") + val removed = session("removed", "removed-secret") + val recoveryDocument = NextcloudDocumentIds.documentId(removed, "Sync/.nextcloud-native-stage") + val unrelatedDocument = NextcloudDocumentIds.documentId(removed, "Sync/private.txt") + val permitInstalled = CountDownLatch(1) + val finishRecovery = CountDownLatch(1) + + val recovery = thread(start = true) { + withAndroidDocumentsProviderRecoveryPermit( + removed, + recoveryDocument, + AndroidDocumentsProviderRecoveryOperation.Rename, + ) { + permitInstalled.countDown() + finishRecovery.await() + } + } + permitInstalled.await() + try { + assertEquals( + null, + resolveSession(unrelatedDocument, AndroidDocumentsProviderRecoveryOperation.Rename) { active }, + ) + assertEquals( + null, + resolveSession(recoveryDocument, AndroidDocumentsProviderRecoveryOperation.OpenWrite) { active }, + ) + assertEquals( + null, + resolveSession(recoveryDocument, AndroidDocumentsProviderRecoveryOperation.Rename) { active }?.session, + ) + } finally { + finishRecovery.countDown() + recovery.join() + } + } + + @Test + fun `read descriptor session survives its consumed permit without enabling write open`() { + val removed = session("removed", "removed-secret") + val documentId = NextcloudDocumentIds.documentId(removed, "Sync/recovery-backup") + lateinit var descriptorSession: AndroidDocumentsProviderResolvedSession + + withAndroidDocumentsProviderRecoveryPermit( + removed, + documentId, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { + descriptorSession = checkNotNull( + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.OpenRead) { null }, + ) + assertEquals( + null, + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.OpenWrite) { null }, + ) + } + + assertEquals(removed, descriptorSession.session) + assertTrue(descriptorSession.recoveryAuthorized) + assertEquals( + null, + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.OpenRead) { null }, + ) + } + + @Test + fun `recovery permit does not change external handoff session resolution`() { + val active = session("active", "active-secret") + val handoffDocumentId = "nch1:0123456789abcdef0123456789abcdef" + + assertEquals( + active, + requireAndroidDocumentsProviderCallSession( + handoffDocumentId, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { active }.session, + ) + } + + @Test + fun `recovery permit wins over a new credential incarnation of the same account`() { + val removed = session("same-account", "removed-secret") + val replacement = session("same-account", "replacement-secret") + val documentId = NextcloudDocumentIds.documentId(removed, "Sync/recovery-backup") + + withAndroidDocumentsProviderRecoveryPermit( + removed, + documentId, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { + val recovery = resolveSession( + documentId, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { replacement } + assertEquals(removed, recovery?.session) + assertTrue(recovery?.recoveryAuthorized == true) + + val ordinary = resolveSession( + documentId, + AndroidDocumentsProviderRecoveryOperation.OpenRead, + ) { replacement } + assertEquals(replacement, ordinary?.session) + assertFalse(ordinary?.recoveryAuthorized == true) + } + } + + @Test + fun `recovery uri helpers use direct provider calls and deny unsupported operations`() { + fun recoveryUri(operation: AndroidDocumentsProviderRecoveryOperation) = + androidDocumentsProviderRecoveryUri( + documentId = "document-id", + operation = operation, + buildDocumentUri = { id -> "document:$id" }, + buildChildDocumentsUri = { id -> "children:$id" }, + ) + + assertEquals("children:document-id", recoveryUri(AndroidDocumentsProviderRecoveryOperation.QueryChildren)) + assertEquals("document:document-id", recoveryUri(AndroidDocumentsProviderRecoveryOperation.OpenRead)) + assertEquals("document:document-id", recoveryUri(AndroidDocumentsProviderRecoveryOperation.Rename)) + assertEquals("document:document-id", recoveryUri(AndroidDocumentsProviderRecoveryOperation.Delete)) + listOf( + AndroidDocumentsProviderRecoveryOperation.QueryDocument, + AndroidDocumentsProviderRecoveryOperation.OpenWrite, + AndroidDocumentsProviderRecoveryOperation.Create, + AndroidDocumentsProviderRecoveryOperation.Move, + ).forEach { operation -> + assertFailsWith { recoveryUri(operation) } + } + + val session = session("removed", "removed-secret") + val documentId = NextcloudDocumentIds.documentId(session, "Sync") + assertFailsWith { + withAndroidDocumentsProviderRecoveryPermit( + session, + documentId, + AndroidDocumentsProviderRecoveryOperation.OpenWrite, + ) { + error("write recovery must remain unreachable") + } + } + } + + @Test + fun `recovery rename normalizes a changed document id back to the durable tree`() { + val normalized = normalizeAndroidDocumentsProviderRecoveryResult( + recoveryEnabled = true, + document = "tree:old-id", + result = "direct:new-id", + documentIdOf = { result -> result.substringAfter(':') }, + buildTreeDocumentUri = { _, id -> "tree:$id" }, + ) + val ordinary = normalizeAndroidDocumentsProviderRecoveryResult( + recoveryEnabled = false, + document = "tree:old-id", + result = "tree:new-id", + documentIdOf = { error("ordinary results stay unchanged") }, + buildTreeDocumentUri = { _, _ -> error("ordinary results stay unchanged") }, + ) + + assertEquals("tree:new-id", normalized) + assertEquals("tree:new-id", ordinary) + } + + @Test + fun `recovery permit is cleared when recovery fails`() { + val removed = session("removed", "removed-secret") + val documentId = NextcloudDocumentIds.documentId(removed, "Sync") + + assertFailsWith { + withAndroidDocumentsProviderRecoveryPermit( + removed, + documentId, + AndroidDocumentsProviderRecoveryOperation.QueryChildren, + ) { + throw IOException("synthetic recovery failure") + } + } + + assertEquals( + null, + resolveSession(documentId, AndroidDocumentsProviderRecoveryOperation.QueryChildren) { null }, + ) + } + + @Test + fun `recovery mutation uses the account lease held by credential removal`() = runBlocking { + val removed = session("removed", "removed-secret") + val documentId = NextcloudDocumentIds.documentId(removed, "Sync/recovery-backup") + val guard = AndroidAccountOperationGuard() + var mutationEntered = false + + guard.withAccount(NextcloudDocumentIds.accountKey(removed)) { + withAndroidDocumentsProviderRecoveryPermit( + removed, + documentId, + AndroidDocumentsProviderRecoveryOperation.Rename, + ) { + val resolved = requireNotNull( + resolveAndroidDocumentsProviderSession( + documentId, + AndroidDocumentsProviderRecoveryOperation.Rename, + allowRecoveryPermit = true, + loadActiveSession = { null }, + ), + ) + withResolvedAndroidDocumentsProviderMutation( + resolved, + loadActiveSession = { null }, + guard = guard, + ) { session -> + assertEquals(removed, session) + mutationEntered = true + } + } + } + + assertTrue(mutationEntered) + } + @Test fun `account removal preflight runs before remote credential revocation`() = runBlocking { var revoked = false @@ -125,4 +438,22 @@ class NextcloudDocumentsContractTest { assertTrue(operation.isCancelled) assertTrue(removalCompleted.isCompleted) } + + private fun session(loginName: String, appPassword: String) = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = loginName, + appPassword = appPassword, + ) + + private fun resolveSession( + documentId: String, + operation: AndroidDocumentsProviderRecoveryOperation, + allowRecoveryPermit: Boolean = true, + loadActiveSession: () -> NextcloudSession?, + ): AndroidDocumentsProviderResolvedSession? = resolveAndroidDocumentsProviderSession( + documentId = documentId, + operation = operation, + allowRecoveryPermit = allowRecoveryPermit, + loadActiveSession = loadActiveSession, + ) } diff --git a/changes/unreleased/446-cross-account-provider-recovery.md b/changes/unreleased/446-cross-account-provider-recovery.md new file mode 100644 index 000000000..a381555d3 --- /dev/null +++ b/changes/unreleased/446-cross-account-provider-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Recover legacy sync folders owned by another account through their original provider and retained permission, preserving pending recovery when that provider is unavailable. diff --git a/changes/unreleased/446-deferred-provider-retirement.md b/changes/unreleased/446-deferred-provider-retirement.md new file mode 100644 index 000000000..93efe79d0 --- /dev/null +++ b/changes/unreleased/446-deferred-provider-retirement.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Allow re-added accounts to finish pending provider reads and retain folder access until interrupted sync retirement has completed. diff --git a/changes/unreleased/446-provider-recovery-boundaries.md b/changes/unreleased/446-provider-recovery-boundaries.md new file mode 100644 index 000000000..ad8a3c2b4 --- /dev/null +++ b/changes/unreleased/446-provider-recovery-boundaries.md @@ -0,0 +1,7 @@ +category: security +issue: none +pull: 446 +platforms: android +user-facing: yes + +Bind removal recovery to its supplied session, require server-confirmed listings, and recheck downloads and range reads before deleting credentials. Recover legacy cross-profile roots through the verified local provider. diff --git a/changes/unreleased/446-recovery-authentication-ambiguity.md b/changes/unreleased/446-recovery-authentication-ambiguity.md new file mode 100644 index 000000000..1e85b351d --- /dev/null +++ b/changes/unreleased/446-recovery-authentication-ambiguity.md @@ -0,0 +1,7 @@ +category: security +issue: none +pull: 446 +platforms: android +user-facing: yes + +Require authoritative content for folder recovery and preserve pending recovery when copied files make the owned location ambiguous. diff --git a/changes/unreleased/446-recovery-generation-preconditions.md b/changes/unreleased/446-recovery-generation-preconditions.md new file mode 100644 index 000000000..260efde3a --- /dev/null +++ b/changes/unreleased/446-recovery-generation-preconditions.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Preserve concurrently replaced recovery files by binding cleanup to the exact verified server generation. Keep directory recovery pending when aggregate generation evidence is unavailable. diff --git a/changes/unreleased/446-relocated-backup-recovery.md b/changes/unreleased/446-relocated-backup-recovery.md new file mode 100644 index 000000000..a70255e60 --- /dev/null +++ b/changes/unreleased/446-relocated-backup-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Recover moved deletion backups using their recorded identity and content evidence, while preserving ambiguous or unverified recovery files. diff --git a/changes/unreleased/446-relocated-provider-recovery.md b/changes/unreleased/446-relocated-provider-recovery.md new file mode 100644 index 000000000..d1d5fce94 --- /dev/null +++ b/changes/unreleased/446-relocated-provider-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Find authenticated pending download recovery files moved outside an old sync subtree when account-bound provider access is available, while preserving other providers' permission boundaries. diff --git a/changes/unreleased/446-retirement-token-isolation.md b/changes/unreleased/446-retirement-token-isolation.md new file mode 100644 index 000000000..b3464f6a3 --- /dev/null +++ b/changes/unreleased/446-retirement-token-isolation.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Keep relocated download recovery limited to the selected sync tree's owned transactions, preserving unrelated pending edits found elsewhere in the same account. diff --git a/changes/unreleased/android-picker-provider-feedback.md b/changes/unreleased/android-picker-provider-feedback.md new file mode 100644 index 000000000..3205bd36f --- /dev/null +++ b/changes/unreleased/android-picker-provider-feedback.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 446 +platforms: android +user-facing: yes + +Android pickers now reject the app's own document provider, ensuring uploads and sync roots use independent storage. Legacy self-provider cleanup can read uncached replacement files without blocking account removal. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 015149f37..7f4b68954 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,6 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|986 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1883 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798