diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e19c533..d9b3a515d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,23 +23,48 @@ jobs: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Set up Rust - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable - - - name: Detect app build changes + - name: Detect build scopes id: changes uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 with: filters: | - app: + rust: - ".github/workflows/ci.yml" - - "androidApp/**" - - "contractAcquisition/**" - - "ui/**" - "src/**" - "tests/**" - "Cargo.toml" - "Cargo.lock" + contract: + - ".github/workflows/ci.yml" + - "contractAcquisition/**" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - "gradlew.bat" + desktop: + - ".github/workflows/ci.yml" + - "contractAcquisition/**" + - "ui/build.gradle.kts" + - "ui/src/commonMain/**" + - "ui/src/commonTest/**" + - "ui/src/desktopMain/**" + - "ui/src/desktopTest/**" + - "build.gradle.kts" + - "settings.gradle.kts" + - "gradle.properties" + - "gradle/**" + - "gradlew" + - "gradlew.bat" + android: + - ".github/workflows/ci.yml" + - "androidApp/**" + - "contractAcquisition/**" + - "ui/build.gradle.kts" + - "ui/src/commonMain/**" + - "ui/src/androidMain/**" + - "ui/src/androidUnitTest/**" - "build.gradle.kts" - "settings.gradle.kts" - "gradle.properties" @@ -63,6 +88,9 @@ jobs: - "website/scripts/marketing-captures.mjs" - "website/scripts/verify-marketing-captures.mjs" + - name: Set up Rust + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable + - name: Check repository hygiene run: | bash tools/check-repository.sh @@ -118,15 +146,34 @@ jobs: --head "${HEAD_SHA}" - name: Set up JDK 21 - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.contract == 'true' || + steps.changes.outputs.desktop == 'true' || + steps.changes.outputs.android == 'true' uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5 with: distribution: temurin java-version: "21" - cache: gradle + + - name: Set up Gradle build cache + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.contract == 'true' || + steps.changes.outputs.desktop == 'true' || + steps.changes.outputs.android == 'true' + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + + - name: Install Linux virtual filesystem runtime + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.desktop == 'true' + run: | + sudo apt-get update + sudo apt-get install --yes libfuse2t64 - name: Cache Cargo dependencies and build output - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.rust == 'true' uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | @@ -138,30 +185,48 @@ jobs: ${{ runner.os }}-cargo-v1- - name: Set up Android SDK - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.android == 'true' uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4 - name: Install Android platform - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.android == 'true' run: sdkmanager "platforms;android-36" "build-tools;35.0.0" - name: Test semantic compiler - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.rust == 'true' run: cargo test --locked - - name: Test shared UI and build desktop plus Android - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + - name: Test and build selected Gradle scopes + if: >- + github.event_name == 'workflow_dispatch' || + steps.changes.outputs.contract == 'true' || + steps.changes.outputs.desktop == 'true' || + steps.changes.outputs.android == 'true' + env: + RUN_CONTRACT: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.contract == 'true' }} + RUN_DESKTOP: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.desktop == 'true' }} + RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' || steps.changes.outputs.android == 'true' }} run: | - ./gradlew --no-daemon \ - :contractAcquisition:test \ - :ui:desktopTest \ - :androidApp:testDebugUnitTest \ - :androidApp:verifyReleaseLintGate \ - :ui:createDistributable \ - :androidApp:assembleDebug + set -euo pipefail + gradle_tasks=() + if [[ "${RUN_CONTRACT}" == "true" ]]; then + gradle_tasks+=(":contractAcquisition:test") + fi + if [[ "${RUN_DESKTOP}" == "true" ]]; then + gradle_tasks+=(":ui:desktopTest" ":ui:createDistributable") + fi + if [[ "${RUN_ANDROID}" == "true" ]]; then + gradle_tasks+=( + ":androidApp:testDebugUnitTest" + ":androidApp:verifyReleaseLintGate" + ":androidApp:assembleDebug" + ) + fi + printf 'Selected Gradle tasks: %s\n' "${gradle_tasks[*]}" + ./gradlew --no-daemon "${gradle_tasks[@]}" - name: Upload Linux desktop app - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.desktop == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nextcloud-native-linux @@ -170,7 +235,7 @@ jobs: retention-days: 7 - name: Upload Android debug APK - if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.app == 'true' + if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.android == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: nextcloud-native-android-debug @@ -179,5 +244,10 @@ jobs: retention-days: 7 - name: Report skipped app build - if: github.event_name != 'workflow_dispatch' && steps.changes.outputs.app != 'true' - run: echo "No app source or build-system changes; compilation was skipped." >>"${GITHUB_STEP_SUMMARY}" + if: >- + github.event_name != 'workflow_dispatch' && + steps.changes.outputs.rust != 'true' && + steps.changes.outputs.contract != 'true' && + steps.changes.outputs.desktop != 'true' && + steps.changes.outputs.android != 'true' + run: echo "No compiler, contract, desktop, Android, or build-system changes; compilation was skipped." >>"${GITHUB_STEP_SUMMARY}" diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 0fd07cd39..6543162f3 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -95,6 +95,11 @@ jobs: java-version: "21" cache: gradle + - name: Install Linux virtual filesystem runtime + run: | + sudo apt-get update + sudo apt-get install --yes libfuse2t64 + - name: Set up Rust uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCacheInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCacheInstrumentedTest.kt new file mode 100644 index 000000000..2ebcac551 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCacheInstrumentedTest.kt @@ -0,0 +1,266 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.VirtualFileCachePolicy +import java.io.File +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.json.JSONObject + +@RunWith(AndroidJUnit4::class) +class AndroidVirtualFileCacheInstrumentedTest { + private lateinit var context: Context + private val session = NextcloudSession( + serverUrl = "https://cloud.invalid", + loginName = "virtual-cache-fixture", + appPassword = "x", + ) + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + File(context.cacheDir, "virtual-files-v1").deleteRecursively() + File(context.filesDir, "documents-recovery").deleteRecursively() + context.getSharedPreferences("virtual-file-cache-v1", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @After + fun tearDown() { + File(context.cacheDir, "virtual-files-v1").deleteRecursively() + File(context.filesDir, "documents-recovery").deleteRecursively() + context.getSharedPreferences("virtual-file-cache-v1", Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun hydrationPublishesExactGenerationAndAnActiveLeaseBlocksEviction() { + val cache = AndroidVirtualFileCache(context) + val bytes = "synthetic virtual content".encodeToByteArray() + val staging = cache.createHydrationStagingFile().apply { writeBytes(bytes) } + val file = NextcloudFile( + path = "Studio/Client selects/portrait.raf", + name = "portrait.raf", + isDirectory = false, + mimeType = "image/x-fuji-raf", + size = bytes.size.toLong(), + lastModified = null, + fileId = 42L, + hasPreview = true, + etag = "\"raf-v1\"", + ) + + cache.publishHydration(session, file, staging, nowEpochMillis = 10L) + val lease = requireNotNull( + cache.acquire(session, file.path, expectedRemoteEtag = "\"raf-v1\"", nowEpochMillis = 20L), + ) + assertArrayEquals(bytes, lease.content.readBytes()) + assertNull(cache.acquire(session, file.path, expectedRemoteEtag = "\"raf-v2\"")) + + val secondCache = AndroidVirtualFileCache(context) + secondCache.savePolicy( + VirtualFileCachePolicy( + automaticCleanup = true, + maximumCacheBytes = 1L, + minimumFreeSpaceBytes = 0L, + unusedFileAgeMillis = null, + ), + ) + val secondLease = requireNotNull(secondCache.acquire(session, file.path, expectedRemoteEtag = "\"raf-v1\"")) + secondLease.release() + + lease.release() + cache.freeUp(session, bytes.size.toLong()) + assertNull(cache.acquire(session, file.path, expectedRemoteEtag = "\"raf-v1\"")) + } + + @Test + fun cacheStartupReclaimsInterruptedOwnedHydrationStages() { + val stagingDirectory = File(context.cacheDir, "virtual-files-v1/staging").apply { mkdirs() } + val interrupted = File(stagingDirectory, "hydrate-interrupted.part").apply { + writeText("partial bytes") + } + val unrelated = File(stagingDirectory, "user-file.part").apply { writeText("preserve") } + + AndroidVirtualFileCache(context) + + assertFalse(interrupted.exists()) + org.junit.Assert.assertTrue(unrelated.exists()) + } + + @Test + fun durableDocumentWritebackManifestIsScopedAndRequiresItsStage() { + val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() } + val stage = File(recovery, "writeback-fixture.stage").apply { writeText("local edit") } + File(recovery, stage.name + ".json").writeText( + JSONObject() + .put("version", 1) + .put("account", NextcloudDocumentIds.accountKey(session)) + .put("path", "Notes/draft.md") + .put("etag", "\"v1\"") + .put("displayName", "draft.md") + .put("stage", stage.name) + .put("startedAt", 10L) + .put("ready", true) + .toString(), + ) + + assertEquals(1, androidDocumentPendingWritebackCount(context, session)) + assertEquals( + "Notes/draft.md", + androidDocumentPendingWriteback(context, session, "Notes/draft.md")?.remotePath, + ) + stage.delete() + assertEquals(0, androidDocumentPendingWritebackCount(context, session)) + } + + @Test + fun providerStartupDiscardsIncompleteWritebacksAndKeepsReadyRecovery() { + val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() } + fun writeTransaction(name: String, ready: Boolean) { + val stage = File(recovery, "writeback-$name.stage").apply { writeText("local edit") } + File(recovery, stage.name + ".json").writeText( + JSONObject() + .put("version", 1) + .put("account", NextcloudDocumentIds.accountKey(session)) + .put("path", "Notes/$name.md") + .put("etag", "\"v1\"") + .put("displayName", "$name.md") + .put("stage", stage.name) + .put("startedAt", 10L) + .put("ready", ready) + .toString(), + ) + } + writeTransaction("unfinished", ready = false) + writeTransaction("recoverable", ready = true) + File(recovery, "writeback-orphan.stage").writeText("partial") + File(recovery, "manifest-orphan.tmp").writeText("partial") + + assertEquals(4, cleanupIncompleteAndroidDocumentWritebacks(context)) + assertEquals(1, androidDocumentPendingWritebackCount(context, session)) + assertEquals( + "Notes/recoverable.md", + androidDocumentPendingWritebacks(context, session).single().remotePath, + ) + } + + @Test + fun activeDocumentWritebackIsHiddenUntilItsDescriptorReleasesIt() { + val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() } + val stage = File(recovery, "writeback-active.stage").apply { writeText("open edit") } + val manifest = File(recovery, stage.name + ".json").apply { + writeText( + JSONObject() + .put("version", 1) + .put("account", NextcloudDocumentIds.accountKey(session)) + .put("path", "Notes/open.md") + .put("etag", "\"v1\"") + .put("displayName", "open.md") + .put("stage", stage.name) + .put("startedAt", 10L) + .put("ready", false) + .toString(), + ) + } + val active = AndroidDocumentPendingWriteback( + stage, + manifest, + NextcloudDocumentIds.accountKey(session), + "Notes/open.md", + "\"v1\"", + ) + + active.markReadyAndActive() + assertEquals(0, androidDocumentPendingWritebackCount(context, session)) + active.releaseActive() + assertEquals(1, androidDocumentPendingWritebackCount(context, session)) + } + + @Test + fun ambiguousWritebackCanBePersistedAsAnExplicitConflict() { + val recovery = File(context.filesDir, "documents-recovery").apply { mkdirs() } + val stage = File(recovery, "writeback-conflict.stage").apply { writeText("local edit") } + val manifest = File(recovery, stage.name + ".json").apply { + writeText( + JSONObject() + .put("version", 1) + .put("account", NextcloudDocumentIds.accountKey(session)) + .put("path", "Notes/conflict.md") + .put("etag", "\"v1\"") + .put("displayName", "conflict.md") + .put("stage", stage.name) + .put("startedAt", 10L) + .put("ready", true) + .toString(), + ) + } + val pending = AndroidDocumentPendingWriteback( + stage, + manifest, + NextcloudDocumentIds.accountKey(session), + "Notes/conflict.md", + "\"v1\"", + ) + + pending.markConflict("\"v2\"") + + assertEquals(true, androidDocumentPendingWritebacks(context, session).single().conflict) + assertEquals("local edit", stage.readText()) + } + + @Test + fun corruptedSameLengthBlobIsRejectedAndRemoved() { + val cache = AndroidVirtualFileCache(context) + val bytes = "first payload".encodeToByteArray() + val file = NextcloudFile( + path = "Notes/digest.txt", + name = "digest.txt", + isDirectory = false, + mimeType = "text/plain", + size = bytes.size.toLong(), + lastModified = null, + fileId = 7L, + hasPreview = false, + etag = "\"digest-v1\"", + ) + cache.publishHydration( + session, + file, + cache.createHydrationStagingFile().apply { writeBytes(bytes) }, + ) + val lease = requireNotNull(cache.acquire(session, file.path, file.etag)) + lease.content.writeBytes("other payload".encodeToByteArray()) + lease.release() + + assertNull(cache.acquire(session, file.path, file.etag)) + } + + @Test + fun writebackAdmissionPreservesAFreeSpaceReserve() { + requireAndroidDocumentWritebackCapacity( + remoteSize = 64L * 1024L * 1024L, + availableBytes = 1024L * 1024L * 1024L, + ) + org.junit.Assert.assertThrows(IllegalArgumentException::class.java) { + requireAndroidDocumentWritebackCapacity( + remoteSize = 700L * 1024L * 1024L, + availableBytes = 1024L * 1024L * 1024L, + ) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index cb453eacd..359b6e52a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -29,6 +29,7 @@ import dev.obiente.nextcloudnative.app.resolveFileSyncDecision import dev.obiente.nextcloudnative.app.retryFileSyncOperation import dev.obiente.nextcloudnative.app.scanFileSyncPair import dev.obiente.nextcloudnative.app.toCenterSummary +import dev.obiente.nextcloudnative.app.includesSyncPath import java.io.File import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -344,7 +345,11 @@ internal class AndroidFileSyncEngine(context: Context) { initialPair.remoteRootPath, webDav, ) - var remoteEntries = remote.scan().map(AndroidRemoteSyncDocument::entry) + val configuration = initialPair.configuration + val includes: (String, SyncEntryKind) -> Boolean = { relativePath, kind -> + configuration.includesSyncPath(relativePath, kind) + } + var remoteEntries = remote.scan(includes).map(AndroidRemoteSyncDocument::entry) val contentHashPaths = remoteEntries .asSequence() .filter { it.kind == SyncEntryKind.File && it.contentHash != null } @@ -354,7 +359,7 @@ internal class AndroidFileSyncEngine(context: Context) { initialPair.localRootId, contentHashPaths, ) - var localEntries = local.scan().map(AndroidLocalSyncDocument::entry) + var localEntries = local.scan(includes).map(AndroidLocalSyncDocument::entry) val baselineByPath = initialPair.baselines.associateBy(FileSyncBaseline::relativePath) val remoteByPath = remoteEntries.associateBy { it.relativePath } val verifiedContentPaths = localEntries diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt index 97d23a29c..8c838b25a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncLocalTree.kt @@ -19,7 +19,9 @@ internal data class AndroidLocalSyncDocument( ) internal interface AndroidFileSyncLocalTree { - fun scan(): List + fun scan( + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean = { _, _ -> true }, + ): List fun stageForUpload(path: String, destination: File, maximumBytes: Long): LocalSyncEntry fun createDirectory(path: String, expectedLocalRevision: String?) fun writeFile(path: String, source: File, expectedLocalRevision: String?) @@ -51,7 +53,9 @@ internal class AndroidSafFileSyncLocalTree( ) { "Access to the selected local folder has expired. Select it again." } } - override fun scan(): List { + override fun scan( + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean, + ): List { val result = ArrayList() val pending = ArrayDeque>() pending += "" to rootUri @@ -59,6 +63,7 @@ internal class AndroidSafFileSyncLocalTree( val (parentPath, parentUri) = pending.removeFirst() require(parentPath.count { it == '/' } < MAX_DEPTH) { "The local folder is nested too deeply." } for (document in children(parentUri, parentPath)) { + if (!includes(document.entry.relativePath, document.entry.kind)) continue require(result.size < MAX_ENTRIES) { "The local folder contains too many entries." } result += document if (document.entry.kind == SyncEntryKind.Directory) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRemoteTree.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRemoteTree.kt index 2ec278349..43dc2d702 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRemoteTree.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncRemoteTree.kt @@ -23,7 +23,9 @@ internal class AndroidFileSyncRemoteTree( ) { private val rootPath = remoteRootPath.trim('/') - fun scan(): List { + fun scan( + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean = { _, _ -> true }, + ): List { val result = ArrayList() val pending = ArrayDeque() pending += "" @@ -39,13 +41,15 @@ internal class AndroidFileSyncRemoteTree( require(!listing.limited) { "A Nextcloud folder contains too many entries to sync safely." } listing.files.forEach { file -> val relativePath = toRelativePath(file.path) ?: return@forEach + val kind = if (file.isDirectory) SyncEntryKind.Directory else SyncEntryKind.File + if (!includes(relativePath, kind)) return@forEach require(result.size < MAX_ENTRIES) { "The Nextcloud folder contains too many entries." } val etag = file.etag?.takeIf(String::isNotBlank) ?: error("Refresh failed because ${file.name} has no server revision.") val document = AndroidRemoteSyncDocument( entry = RemoteSyncEntry( relativePath = relativePath, - kind = if (file.isDirectory) SyncEntryKind.Directory else SyncEntryKind.File, + kind = kind, etag = etag, size = if (file.isDirectory) null else file.size, contentHash = if (file.isDirectory) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt index 9d7dc43fb..246fbe426 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaStoreSyncLocalTree.kt @@ -53,8 +53,12 @@ internal class AndroidMediaStoreSyncLocalTree( require(root.isDirectory && root.canRead()) { "The detected media folder is unavailable." } } - override fun scan(): List { - return mediaFolderSyncFiles(root).map { file -> file.toSyncDocument(file.name) } + override fun scan( + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean, + ): List { + return mediaFolderSyncFiles(root) + .map { file -> file.toSyncDocument(file.name) } + .filter { document -> includes(document.entry.relativePath, document.entry.kind) } } override fun stageForUpload(path: String, destination: File, maximumBytes: Long): LocalSyncEntry { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index cd3c65ad7..8bb3d24f7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -51,6 +51,13 @@ import dev.obiente.nextcloudnative.app.FileSyncCenterSnapshot import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncDecisionChoice import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import dev.obiente.nextcloudnative.app.VirtualFileCachePolicy +import dev.obiente.nextcloudnative.app.VirtualFilePlatformIntegration +import dev.obiente.nextcloudnative.app.VirtualFileProviderState +import dev.obiente.nextcloudnative.app.VirtualFileStorageActionResult +import dev.obiente.nextcloudnative.app.VirtualFileStorageSnapshot +import dev.obiente.nextcloudnative.app.VirtualFileStorageSupport +import dev.obiente.nextcloudnative.app.formatVirtualFileBytes import dev.obiente.nextcloudnative.app.MediaSyncFolderDiscovery import dev.obiente.nextcloudnative.app.MAX_MEDIA_BACKUP_STATUS_PATHS import dev.obiente.nextcloudnative.app.MediaBackupStatus @@ -156,7 +163,9 @@ import dev.obiente.nextcloudnative.contracts.SignedAppStoreContractAcquirer import dev.obiente.nextcloudnative.contracts.VerifiedContractKind import java.io.File import java.io.ByteArrayOutputStream +import java.io.FileInputStream import java.io.IOException +import java.io.OutputStream import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -272,6 +281,7 @@ internal class AndroidNextcloudServices( ) private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) + private val virtualFileCache = AndroidVirtualFileCache(appContext) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache( File(appContext.cacheDir, "native-media-previews-v1"), @@ -295,6 +305,7 @@ internal class AndroidNextcloudServices( private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) override val supportsFileOfflineStorage: Boolean = true + override val supportsVirtualFileStorage: Boolean = true override val supportsRecursiveFileOfflineStorage: Boolean = true override val supportsBidirectionalFileSync: Boolean = fileSyncRootPicker != null override val externalFileHandoffSupport: ExternalFileHandoffSupport = ExternalFileHandoffSupport.Available( @@ -564,6 +575,23 @@ internal class AndroidNextcloudServices( ) } + private fun notifyDocumentsDocumentChanged(session: NextcloudSession, path: String) { + appContext.contentResolver.notifyChange( + DocumentsContract.buildDocumentUri( + NEXTCLOUD_DOCUMENTS_AUTHORITY, + NextcloudDocumentIds.documentId(session, path), + ), + null, + ) + appContext.contentResolver.notifyChange( + DocumentsContract.buildChildDocumentsUri( + NEXTCLOUD_DOCUMENTS_AUTHORITY, + NextcloudDocumentIds.documentId(session, NextcloudDocumentIds.parentPath(path)), + ), + null, + ) + } + override suspend fun beginLogin(serverUrl: String): LoginChallenge = withContext(Dispatchers.IO) { val baseUrl = normalizeServerUrl(serverUrl) val response = request(method = "POST", url = "$baseUrl/index.php/login/v2") @@ -715,6 +743,136 @@ internal class AndroidNextcloudServices( fileOfflineRepository.removeCenterItem(session, userId, key) } + override suspend fun loadVirtualFileStorage( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageSnapshot = withContext(Dispatchers.IO) { + val cache = virtualFileCache.summary(session) + val offline = fileOfflineRepository.loadCenter(session) + val documentWritebacks = androidDocumentPendingWritebacks(appContext, session) + if (documentWritebacks.isNotEmpty()) { + val webDav = NextcloudDocumentWebDav(cloudMutationsAllowed = appContext.cloudMutationGate()) + documentWritebacks.forEach { discovered -> + val pending = claimAndroidDocumentPendingWritebackForRecovery( + appContext, + session, + discovered.remotePath, + ) ?: return@forEach + runCatching { + if (pending.conflict) { + pending.releaseActive() + return@runCatching + } + requireAndroidDocumentStagedWritebackCapacity( + stagedBytes = pending.staging.length(), + availableBytes = pending.staging.parentFile?.usableSpace ?: 0L, + ) + val remote = compareAndroidDocumentWriteback( + webDav = webDav, + session = session, + userId = userId, + pending = pending, + ) + if (remote.contentsMatch) { + virtualFileCache.invalidate(session, pending.remotePath) + notifyDocumentsDocumentChanged(session, pending.remotePath) + pending.complete() + return@runCatching + } + if (remote.etag == null || remote.etag != pending.expectedRemoteEtag) { + pending.markConflict(remote.etag) + pending.releaseActive() + return@runCatching + } + webDav.replaceFileAtomically( + session = session, + userId = userId, + path = pending.remotePath, + source = pending.staging, + expectedEtag = pending.expectedRemoteEtag, + ) + virtualFileCache.invalidate(session, pending.remotePath) + notifyDocumentsDocumentChanged(session, pending.remotePath) + pending.complete() + }.onFailure { pending.releaseActive() } + } + } + val pendingWritebacks = androidDocumentPendingWritebackCount(appContext, session) + val conflictedWritebacks = androidDocumentPendingWritebacks(appContext, session).count { it.conflict } + VirtualFileStorageSnapshot( + support = VirtualFileStorageSupport.Available, + integration = VirtualFilePlatformIntegration.AndroidDocumentsProvider, + policy = cache.policy, + cachedBytes = cache.cachedBytes, + reclaimableBytes = cache.reclaimableBytes, + pinnedBytes = offline.storageUsage?.usedBytes ?: 0L, + hydratedFileCount = cache.entryCount, + pinnedFileCount = offline.items.count { + it.availability == FileOfflineAvailability.Available + }, + availableFreeBytes = cache.availableFreeBytes, + storageCapacityBytes = appContext.cacheDir.totalSpace.takeIf { it > 0L }, + limitations = listOf( + "System Files hydrates remote content on open and reuses complete cached generations.", + "Pinned offline files are durable and are never removed by automatic cache cleanup.", + ) + if (conflictedWritebacks > 0) { + listOf( + "$conflictedWritebacks System Files edit(s) conflict with a newer remote generation and need attention.", + ) + } else if (pendingWritebacks > 0) { + listOf("$pendingWritebacks System Files edit(s) are retained with recovery metadata after failed writeback.") + } else { + emptyList() + }, + providerState = VirtualFileProviderState.Active, + providerLocation = "System Files / Nextcloud Native", + pendingWritebackCount = pendingWritebacks, + ) + } + + override suspend fun activateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Completed( + "Nextcloud Native is already available in System Files.", + ) + + override suspend fun deactivateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Rejected( + "Android manages the System Files provider while this account is signed in.", + ) + + override suspend fun saveVirtualFileCachePolicy( + session: NextcloudSession, + userId: String, + policy: VirtualFileCachePolicy, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + virtualFileCache.savePolicy(policy) + VirtualFileStorageActionResult.Completed("Virtual file storage rules saved.") + } + + override suspend fun freeUpVirtualFileSpace( + session: NextcloudSession, + userId: String, + requestedBytes: Long, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + require(requestedBytes >= 0L) + val before = virtualFileCache.summary(session).cachedBytes + virtualFileCache.freeUp(session, requestedBytes) + val after = virtualFileCache.summary(session).cachedBytes + val freed = (before - after).coerceAtLeast(0L) + VirtualFileStorageActionResult.Completed( + message = if (freed > 0L) { + "Freed ${formatVirtualFileBytes(freed)} of disposable virtual file content." + } else { + "No disposable virtual file content could be freed. Pinned and active files were kept." + }, + freedBytes = freed, + ) + } + override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = checkNotNull(fileSyncRootPicker) { "The native folder chooser is not available from this Android component." @@ -2751,6 +2909,66 @@ private fun org.w3c.dom.Node.fileVersionFirstText(namespace: String, localName: private const val FILE_VERSION_DAV_NAMESPACE = "DAV:" private const val FILE_VERSION_NC_NAMESPACE = "http://nextcloud.org/ns" +private data class AndroidDocumentRemoteComparison( + val contentsMatch: Boolean, + val etag: String?, +) + +private fun compareAndroidDocumentWriteback( + webDav: NextcloudDocumentWebDav, + session: NextcloudSession, + userId: String, + pending: AndroidDocumentPendingWriteback, +): AndroidDocumentRemoteComparison = AndroidDocumentStagingComparator(pending.staging).use { comparison -> + val result = webDav.readFile( + session = session, + userId = userId, + path = pending.remotePath, + destination = comparison, + maximumBytes = MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES, + ) + AndroidDocumentRemoteComparison( + contentsMatch = comparison.matches(result.byteCount), + etag = result.etag, + ) +} + +internal class AndroidDocumentStagingComparator(staging: File) : OutputStream() { + private val expectedLength = staging.length() + private val expected = FileInputStream(staging) + private var matching = true + private var closed = false + + override fun write(value: Int) { + val actual = value and 0xff + val wanted = expected.read() + if (wanted != actual) matching = false + } + + override fun write(bytes: ByteArray, offset: Int, length: Int) { + require(offset >= 0 && length >= 0 && offset <= bytes.size - length) + val wanted = ByteArray(length) + var consumed = 0 + while (consumed < length) { + val read = expected.read(wanted, consumed, length - consumed) + if (read < 0) break + consumed += read + } + if (consumed != length || (0 until length).any { index -> bytes[offset + index] != wanted[index] }) { + matching = false + } + } + + fun matches(remoteBytes: Long): Boolean = + !closed && matching && remoteBytes == expectedLength && expected.read() == -1 + + override fun close() { + if (closed) return + closed = true + expected.close() + } +} + internal fun parseAndroidSystemTagsDavResponse(xml: ByteArray): List { val responses = SafeXmlParser.parse(xml).getElementsByTagNameNS(SYSTEM_TAG_DAV_NAMESPACE, "response") val records = buildList { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt new file mode 100644 index 000000000..1a79c0b28 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt @@ -0,0 +1,573 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.FileOfflineKey +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.VirtualFileActivity +import dev.obiente.nextcloudnative.app.VirtualFileCacheEntry +import dev.obiente.nextcloudnative.app.VirtualFileCachePolicy +import dev.obiente.nextcloudnative.app.VirtualFileEvictionPlan +import dev.obiente.nextcloudnative.app.VirtualFileRetention +import dev.obiente.nextcloudnative.app.planVirtualFileEviction +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.EOFException +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + +internal data class AndroidVirtualFileLease( + val file: NextcloudFile, + val content: File, + val localRevision: String, + val release: () -> Unit, +) + +internal data class AndroidVirtualFileCacheSummary( + val policy: VirtualFileCachePolicy, + val cachedBytes: Long, + val reclaimableBytes: Long, + val entryCount: Int, + val availableFreeBytes: Long, + val lastEvictionPlan: VirtualFileEvictionPlan, +) + +/** + * Disposable hydrate-on-open storage for Android's cloud DocumentsProvider. + * + * This cache is deliberately separate from durable offline pins. Every blob is content-addressed, + * fsynced, and paired with the exact remote ETag that produced it. Eviction is revision guarded and + * refuses active descriptor leases; a process restart may forget leases, but Linux/Android keeps an + * already-open descriptor readable even if its directory entry is later removed. + */ +internal class AndroidVirtualFileCache(context: Context) { + private val appContext = context.applicationContext + private val root = File(appContext.cacheDir, CACHE_DIRECTORY) + private val preferences = appContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + init { + synchronized(STORE_LOCK) { reconcileHydrationStaging() } + } + + fun acquire( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + path: String, + expectedRemoteEtag: String? = null, + nowEpochMillis: Long = System.currentTimeMillis(), + ): AndroidVirtualFileLease? = synchronized(STORE_LOCK) { + require(nowEpochMillis >= 0L) + val accountId = NextcloudDocumentIds.accountKey(session) + val key = FileOfflineKey(accountId, path) + val state = load(accountId) + val record = state.entries.firstOrNull { entry -> + entry.path == key.relativePath && + (expectedRemoteEtag == null || entry.remoteEtag == expectedRemoteEtag) + } ?: return null + val blob = File(accountDirectory(accountId), record.blobName) + if (!runCatching { record.isValidBlob(blob) }.getOrDefault(false)) { + removeInvalidRecord(accountId, state, record, blob) + return null + } + val touched = record.copy(lastAccessedAtEpochMillis = maxOf(record.lastAccessedAtEpochMillis, nowEpochMillis)) + save(accountId, state.copy(entries = state.entries.replace(touched))) + activeLeases[key] = activeLeases.getOrDefault(key, 0) + 1 + var released = false + return AndroidVirtualFileLease( + file = touched.toNextcloudFile(), + content = blob, + localRevision = touched.localRevision, + release = { + synchronized(STORE_LOCK) { + if (!released) { + released = true + val remaining = activeLeases.getOrDefault(key, 1) - 1 + if (remaining <= 0) activeLeases.remove(key) else activeLeases[key] = remaining + } + } + }, + ) + } + + fun cachedEntry( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + path: String, + ): NextcloudFile? = synchronized(STORE_LOCK) { + acquire(session, path)?.let { lease -> + try { + lease.file + } finally { + lease.release() + } + } + } + + fun createHydrationStagingFile(): File = synchronized(STORE_LOCK) { + val directory = File(root, STAGING_DIRECTORY).apply { + check(isDirectory || mkdirs()) { "Could not create virtual file hydration staging." } + } + return File.createTempFile("hydrate-", ".part", directory).also { staging -> + activeHydrationStages += staging.activeHydrationKey() + } + } + + fun discardHydrationStagingFile(staging: File) = synchronized(STORE_LOCK) { + activeHydrationStages -= staging.activeHydrationKey() + staging.delete() + } + + fun canCacheHydration(sizeBytes: Long): Boolean = sizeBytes in 0L..MAX_VIRTUAL_FILE_BYTES + + fun prepareHydration( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + sizeBytes: Long, + ): File? = synchronized(STORE_LOCK) { + if (!canCacheHydration(sizeBytes)) return null + check(root.isDirectory || root.mkdirs()) { "Could not create virtual file cache storage." } + val policy = loadPolicy() + val availableBefore = root.usableSpace.coerceAtLeast(0L) + val requiredBeforeHydration = if (sizeBytes > Long.MAX_VALUE - policy.minimumFreeSpaceBytes) { + Long.MAX_VALUE + } else { + sizeBytes + policy.minimumFreeSpaceBytes + } + val requestedBytes = (requiredBeforeHydration - availableBefore).coerceAtLeast(0L) + if (requestedBytes > 0L && policy.automaticCleanup) { + applyEviction(NextcloudDocumentIds.accountKey(session), requestedBytes) + } + return if ( + androidHydrationFitsCapacity( + sizeBytes = sizeBytes, + availableBytes = root.usableSpace.coerceAtLeast(0L), + reserveBytes = policy.minimumFreeSpaceBytes, + ) + ) { + createHydrationStagingFile() + } else { + null + } + } + + fun publishHydration( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + file: NextcloudFile, + staging: File, + nowEpochMillis: Long = System.currentTimeMillis(), + ): Boolean = synchronized(STORE_LOCK) { + require(!file.isDirectory) + require(nowEpochMillis >= 0L) + val remoteEtag = file.etag?.takeIf(String::isNotBlank) ?: return false + if (!staging.isFile || staging.length() > MAX_VIRTUAL_FILE_BYTES) return false + val accountId = NextcloudDocumentIds.accountKey(session) + val directory = accountDirectory(accountId).apply { + check(isDirectory || mkdirs()) { "Could not create the Android virtual file cache." } + } + var current = load(accountId) + if (current.entries.none { it.path == file.path } && current.entries.size >= MAX_ENTRIES) { + val eviction = current.entries + .asSequence() + .filter { cached -> + activeLeases.getOrDefault(FileOfflineKey(accountId, cached.path), 0) == 0 + } + .minWithOrNull(compareBy { it.lastAccessedAtEpochMillis }.thenBy { it.path }) + ?: return false + current = current.copy(entries = current.entries - eviction) + save(accountId, current) + } + val digest = staging.sha256Hex() + val localRevision = "sha256:$digest" + val blobName = "${sha256Hex("${file.path}\u0000$remoteEtag")}.blob" + val destination = File(directory, blobName) + publishAtomically(staging, destination) + activeHydrationStages -= staging.activeHydrationKey() + val next = current.copy( + entries = current.entries.filterNot { it.path == file.path } + CachedVirtualFile( + path = file.path, + displayName = file.name, + remoteEtag = remoteEtag, + localRevision = localRevision, + mimeType = file.mimeType, + sizeBytes = destination.length(), + blobName = blobName, + cachedAtEpochMillis = nowEpochMillis, + lastAccessedAtEpochMillis = nowEpochMillis, + ), + ) + try { + save(accountId, next) + } catch (failure: Throwable) { + if (current.entries.none { it.blobName == blobName }) destination.delete() + throw failure + } + applyEviction(accountId, requestedBytesToFree = 0L, nowEpochMillis = nowEpochMillis) + return load(accountId).entries.any { it.path == file.path && it.localRevision == localRevision } + } + + fun summary( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + nowEpochMillis: Long = System.currentTimeMillis(), + ): AndroidVirtualFileCacheSummary = synchronized(STORE_LOCK) { + val accountId = NextcloudDocumentIds.accountKey(session) + val entries = load(accountId).entries.toDomain(accountId) + val plan = planVirtualFileEviction( + entries = entries, + policy = loadPolicy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = nowEpochMillis, + ) + return AndroidVirtualFileCacheSummary( + policy = loadPolicy(), + cachedBytes = plan.cachedBytes, + reclaimableBytes = plan.reclaimableBytes, + entryCount = entries.size, + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + lastEvictionPlan = plan, + ) + } + + fun savePolicy(policy: VirtualFileCachePolicy) = synchronized(STORE_LOCK) { + preferences.edit() + .putBoolean(KEY_AUTOMATIC, policy.automaticCleanup) + .putLong(KEY_MAXIMUM_BYTES, policy.maximumCacheBytes ?: UNLIMITED_SENTINEL) + .putLong(KEY_MINIMUM_FREE_BYTES, policy.minimumFreeSpaceBytes) + .putLong(KEY_UNUSED_AGE, policy.unusedFileAgeMillis ?: UNLIMITED_SENTINEL) + .apply() + root.listFiles().orEmpty().filter(File::isDirectory).forEach { directory -> + if (directory.name.isAccountId()) applyEviction(directory.name, requestedBytesToFree = 0L) + } + } + + fun freeUp( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + requestedBytesToFree: Long, + ): VirtualFileEvictionPlan = synchronized(STORE_LOCK) { + require(requestedBytesToFree >= 0L) + return applyEviction(NextcloudDocumentIds.accountKey(session), requestedBytesToFree) + } + + fun invalidate( + session: dev.obiente.nextcloudnative.app.NextcloudSession, + path: String, + ) = synchronized(STORE_LOCK) { + val accountId = NextcloudDocumentIds.accountKey(session) + val normalized = FileOfflineKey(accountId, path).relativePath + val current = load(accountId) + val removed = current.entries.filter { entry -> + entry.path == normalized || entry.path.startsWith("$normalized/") + } + removed.forEach { entry -> File(accountDirectory(accountId), entry.blobName).delete() } + if (removed.isNotEmpty()) { + save(accountId, current.copy(entries = current.entries.filterNot { it in removed })) + } + } + + fun loadPolicy(): VirtualFileCachePolicy = VirtualFileCachePolicy( + automaticCleanup = preferences.getBoolean(KEY_AUTOMATIC, true), + maximumCacheBytes = preferences.getLong( + KEY_MAXIMUM_BYTES, + dev.obiente.nextcloudnative.app.DEFAULT_VIRTUAL_FILE_CACHE_BYTES, + ).optionalPositiveOrDefault(dev.obiente.nextcloudnative.app.DEFAULT_VIRTUAL_FILE_CACHE_BYTES), + minimumFreeSpaceBytes = preferences.getLong( + KEY_MINIMUM_FREE_BYTES, + dev.obiente.nextcloudnative.app.DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES, + ).coerceAtLeast(0L), + unusedFileAgeMillis = preferences.getLong( + KEY_UNUSED_AGE, + dev.obiente.nextcloudnative.app.DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS, + ).optionalPositiveOrDefault(dev.obiente.nextcloudnative.app.DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS), + ) + + private fun Long.optionalPositiveOrDefault(defaultValue: Long): Long? = when { + this == UNLIMITED_SENTINEL -> null + this > 0L -> this + else -> defaultValue + } + + private fun applyEviction( + accountId: String, + requestedBytesToFree: Long, + nowEpochMillis: Long = System.currentTimeMillis(), + ): VirtualFileEvictionPlan { + val current = load(accountId) + val domain = current.entries.toDomain(accountId) + val plan = planVirtualFileEviction( + entries = domain, + policy = loadPolicy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = nowEpochMillis, + requestedBytesToFree = requestedBytesToFree, + ) + val byPath = current.entries.associateBy(CachedVirtualFile::path) + val removedPaths = plan.evictions.mapNotNullTo(mutableSetOf()) { eviction -> + val currentRecord = byPath[eviction.key.relativePath] ?: return@mapNotNullTo null + val active = activeLeases.getOrDefault(eviction.key, 0) + if (active != 0 || currentRecord.localRevision != eviction.expectedLocalRevision) { + return@mapNotNullTo null + } + val blob = File(accountDirectory(accountId), currentRecord.blobName) + if (!blob.exists() || blob.delete()) currentRecord.path else null + } + if (removedPaths.isNotEmpty()) { + save(accountId, current.copy(entries = current.entries.filterNot { it.path in removedPaths })) + } + return plan + } + + private fun removeInvalidRecord(accountId: String, state: CacheState, record: CachedVirtualFile, blob: File) { + blob.delete() + save(accountId, state.copy(entries = state.entries.filterNot { it.path == record.path })) + } + + private fun load(accountId: String): CacheState { + val index = File(accountDirectory(accountId), INDEX_FILE_NAME) + if (!index.isFile || index.length() !in 1L..MAX_INDEX_BYTES) return CacheState() + return try { + DataInputStream(BufferedInputStream(FileInputStream(index))).use { input -> + require(input.readInt() == MAGIC) + require(input.readInt() == FORMAT_VERSION) + val count = input.readInt() + require(count in 0..MAX_ENTRIES) + val entries = List(count) { input.readRecord() } + require(input.read() == -1) + require(entries.map(CachedVirtualFile::path).distinct().size == entries.size) + CacheState(entries) + } + } catch (_: EOFException) { + CacheState() + } catch (_: Exception) { + CacheState() + } + } + + private fun save(accountId: String, state: CacheState) { + require(state.entries.size <= MAX_ENTRIES) + state.entries.forEach(CachedVirtualFile::requireValid) + require(state.entries.map(CachedVirtualFile::path).distinct().size == state.entries.size) + val directory = accountDirectory(accountId).apply { + check(isDirectory || mkdirs()) { "Could not create the Android virtual file cache." } + } + val temporary = File.createTempFile("index-", ".tmp", directory) + try { + FileOutputStream(temporary).use { fileOutput -> + DataOutputStream(BufferedOutputStream(fileOutput)).use { output -> + output.writeInt(MAGIC) + output.writeInt(FORMAT_VERSION) + output.writeInt(state.entries.size) + state.entries.sortedBy(CachedVirtualFile::path).forEach { record -> + output.writeRecord(record) + } + output.flush() + fileOutput.fd.sync() + } + } + require(temporary.length() <= MAX_INDEX_BYTES) + publishAtomically(temporary, File(directory, INDEX_FILE_NAME)) + } finally { + temporary.delete() + } + val referenced = state.entries.mapTo(hashSetOf(), CachedVirtualFile::blobName) + directory.listFiles().orEmpty() + .filter { it.isFile && it.extension == "blob" && it.name !in referenced } + .forEach(File::delete) + } + + private fun DataOutputStream.writeRecord(record: CachedVirtualFile) { + writeString(record.path) + writeString(record.displayName) + writeString(record.remoteEtag) + writeString(record.localRevision) + writeNullableString(record.mimeType) + writeLong(record.sizeBytes) + writeString(record.blobName) + writeLong(record.cachedAtEpochMillis) + writeLong(record.lastAccessedAtEpochMillis) + } + + private fun DataInputStream.readRecord(): CachedVirtualFile = CachedVirtualFile( + path = readString(), + displayName = readString(), + remoteEtag = readString(), + localRevision = readString(), + mimeType = readNullableString(), + sizeBytes = readLong(), + blobName = readString(), + cachedAtEpochMillis = readLong(), + lastAccessedAtEpochMillis = readLong(), + ).also(CachedVirtualFile::requireValid) + + private fun DataOutputStream.writeNullableString(value: String?) { + writeBoolean(value != null) + if (value != null) writeString(value) + } + + private fun DataInputStream.readNullableString(): String? = if (readBoolean()) readString() else null + + private fun DataOutputStream.writeString(value: String) { + val bytes = value.toByteArray(StandardCharsets.UTF_8) + require(bytes.size <= MAX_STRING_BYTES) + writeInt(bytes.size) + write(bytes) + } + + private fun DataInputStream.readString(): String { + val length = readInt() + require(length in 0..MAX_STRING_BYTES) + val bytes = ByteArray(length) + readFully(bytes) + return bytes.toString(StandardCharsets.UTF_8).also { decoded -> + require(decoded.toByteArray(StandardCharsets.UTF_8).contentEquals(bytes)) + } + } + + private fun CachedVirtualFile.isValidBlob(file: File): Boolean = + file.isFile && + file.length() == sizeBytes && + localRevision == "sha256:${file.sha256Hex()}" + + private fun CachedVirtualFile.toNextcloudFile(): NextcloudFile = NextcloudFile( + path = path, + name = displayName, + isDirectory = false, + mimeType = mimeType, + size = sizeBytes, + lastModified = null, + fileId = null, + hasPreview = false, + etag = remoteEtag, + ) + + private fun List.toDomain(accountId: String): List = map { record -> + val key = FileOfflineKey(accountId, record.path) + VirtualFileCacheEntry( + key = key, + remoteRevision = record.remoteEtag, + localRevision = record.localRevision, + sizeBytes = record.sizeBytes, + cachedAtEpochMillis = record.cachedAtEpochMillis, + lastAccessedAtEpochMillis = record.lastAccessedAtEpochMillis, + retention = VirtualFileRetention.Automatic, + activeLeaseCount = activeLeases.getOrDefault(key, 0), + activity = VirtualFileActivity.Idle, + ) + } + + private fun List.replace(record: CachedVirtualFile): List = + filterNot { it.path == record.path } + record + + private fun accountDirectory(accountId: String): File { + require(accountId.isAccountId()) { "Virtual file cache account identity is invalid." } + return File(root, accountId) + } + + private fun reconcileHydrationStaging() { + val directory = File(root, STAGING_DIRECTORY) + if (!directory.isDirectory) return + directory.listFiles().orEmpty() + .filter { staging -> + staging.isFile && + staging.name.startsWith("hydrate-") && + staging.name.endsWith(".part") && + staging.activeHydrationKey() !in activeHydrationStages + } + .forEach(File::delete) + } + + private fun File.activeHydrationKey(): String = absoluteFile.normalize().path + + private fun String.isAccountId(): Boolean = length == 32 && all { it in '0'..'9' || it in 'a'..'f' } + + private fun File.sha256Hex(): String = inputStream().buffered().use { input -> + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + digest.digest().toHex() + } + + private fun sha256Hex(value: String): String = + MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray()).toHex() + + private fun ByteArray.toHex(): String = joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun publishAtomically(source: File, destination: File) { + try { + Files.move( + source.toPath(), + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + private data class CacheState(val entries: List = emptyList()) + + private data class CachedVirtualFile( + val path: String, + val displayName: String, + val remoteEtag: String, + val localRevision: String, + val mimeType: String?, + val sizeBytes: Long, + val blobName: String, + val cachedAtEpochMillis: Long, + val lastAccessedAtEpochMillis: Long, + ) { + fun requireValid() { + FileOfflineKey("00000000000000000000000000000000", path) + require(displayName.isNotBlank() && displayName.toByteArray().size <= MAX_STRING_BYTES) + require(remoteEtag.isNotBlank() && remoteEtag.toByteArray().size <= MAX_STRING_BYTES) + require(localRevision.startsWith("sha256:") && localRevision.length == 71) + require(localRevision.removePrefix("sha256:").all { it in '0'..'9' || it in 'a'..'f' }) + require(mimeType == null || mimeType.toByteArray().size <= MAX_STRING_BYTES) + require(sizeBytes in 0L..MAX_VIRTUAL_FILE_BYTES) + require(blobName.length == 69 && blobName.endsWith(".blob")) + require(blobName.removeSuffix(".blob").all { it in '0'..'9' || it in 'a'..'f' }) + require(cachedAtEpochMillis >= 0L) + require(lastAccessedAtEpochMillis >= cachedAtEpochMillis) + } + } + + private companion object { + val STORE_LOCK = Any() + val activeLeases = mutableMapOf() + val activeHydrationStages = mutableSetOf() + const val CACHE_DIRECTORY = "virtual-files-v1" + const val STAGING_DIRECTORY = "staging" + const val INDEX_FILE_NAME = "index-v1.bin" + const val PREFERENCES_NAME = "virtual-file-cache-v1" + const val KEY_AUTOMATIC = "automatic" + const val KEY_MAXIMUM_BYTES = "maximum-bytes" + const val KEY_MINIMUM_FREE_BYTES = "minimum-free-bytes" + const val KEY_UNUSED_AGE = "unused-age" + const val UNLIMITED_SENTINEL = -1L + const val MAGIC = 0x4e435646 // NCVF + const val FORMAT_VERSION = 1 + const val MAX_ENTRIES = 20_000 + const val MAX_INDEX_BYTES = 8L * 1024L * 1024L + const val MAX_STRING_BYTES = 16 * 1024 + const val MAX_VIRTUAL_FILE_BYTES = 2L * 1024L * 1024L * 1024L + } +} + +internal fun androidHydrationFitsCapacity( + sizeBytes: Long, + availableBytes: Long, + reserveBytes: Long, +): Boolean = + sizeBytes >= 0L && + availableBytes >= 0L && + reserveBytes >= 0L && + availableBytes >= sizeBytes && + availableBytes - sizeBytes >= reserveBytes diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt new file mode 100644 index 000000000..685639eb6 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt @@ -0,0 +1,215 @@ +package dev.obiente.nextcloudnative + +import android.os.OperationCanceledException +import android.os.ProxyFileDescriptorCallback +import android.system.ErrnoException +import android.system.OsConstants +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * Seekable, generation-pinned read-through descriptor for Android's DocumentsProvider. + * + * Reads are expanded to bounded blocks so media applications can seek without downloading the + * entire object. Files small enough for the managed cache are assembled into a sparse staging file + * and published only after every block belongs to the same ETag-pinned range session. + */ +internal class AndroidVirtualFileProxyCallback( + private val source: NextcloudFileRangeSession, + private val staging: File?, + private val publishCompleteHydration: (File) -> Boolean, + private val discardIncompleteHydration: (File) -> Unit = File::delete, + private val blockSizeBytes: Int = DEFAULT_BLOCK_SIZE_BYTES, +) : ProxyFileDescriptorCallback() { + private val cancelled = AtomicBoolean(false) + private val size = source.size + private val effectiveBlockSize = blockSizeBytes.also { require(it > 0) } + private val blockCount = staging?.let { + val count = (size + effectiveBlockSize - 1L) / effectiveBlockSize + require(count <= Int.MAX_VALUE.toLong()) + count.toInt() + } ?: 0 + private val hydratedBlocks = staging?.let { BooleanArray(blockCount) } + private val stagedContent = staging?.let { file -> + RandomAccessFile(file, "rw").also { random -> random.setLength(size) } + } + private var hydratedBlockCount = 0 + private var published = false + private var released = false + + override fun onGetSize(): Long = size + + @Synchronized + override fun onRead(offset: Long, requestedSize: Int, data: ByteArray): Int { + if (released || cancelled.get()) throw OperationCanceledException("Virtual file read cancelled") + if (offset < 0L || requestedSize < 0 || requestedSize > data.size) { + throw ErrnoException("virtual file read", OsConstants.EINVAL) + } + if (offset >= size || requestedSize == 0) return 0 + val readLength = minOf(requestedSize.toLong(), size - offset).toInt() + return try { + val random = stagedContent + val blocks = hydratedBlocks + if (random == null || blocks == null) { + val bytes = readRange(offset, readLength) + bytes.copyInto(data) + bytes.size + } else { + hydrateBlocks(offset, readLength, random, blocks) + random.seek(offset) + random.readFully(data, 0, readLength) + publishIfComplete(random) + readLength + } + } catch (cancelled: OperationCanceledException) { + throw cancelled + } catch (failure: ErrnoException) { + throw failure + } catch (failure: Throwable) { + throw ErrnoException("virtual file read", OsConstants.EIO, failure) + } + } + + @Synchronized + override fun onRelease() { + if (released) return + released = true + cancelled.set(true) + source.close() + runCatching { stagedContent?.close() } + if (!published) staging?.let(discardIncompleteHydration) + } + + fun cancel() { + cancelled.set(true) + source.close() + } + + private fun hydrateBlocks( + offset: Long, + length: Int, + random: RandomAccessFile, + blocks: BooleanArray, + ) { + val firstBlock = (offset / effectiveBlockSize).toInt() + val lastBlock = ((offset + length - 1L) / effectiveBlockSize).toInt() + for (block in firstBlock..lastBlock) { + if (blocks[block]) continue + if (cancelled.get()) throw OperationCanceledException("Virtual file read cancelled") + val blockOffset = block.toLong() * effectiveBlockSize + val blockLength = minOf(effectiveBlockSize.toLong(), size - blockOffset).toInt() + val bytes = readRange(blockOffset, blockLength) + random.seek(blockOffset) + random.write(bytes) + blocks[block] = true + hydratedBlockCount += 1 + } + } + + private fun readRange(offset: Long, length: Int): ByteArray = + runBlocking(Dispatchers.IO) { source.read(offset, length) }.also { bytes -> + check(bytes.size == length) { "The virtual file range was incomplete." } + } + + private fun publishIfComplete(random: RandomAccessFile) { + if (published || hydratedBlockCount != blockCount) return + random.fd.sync() + val target = staging ?: return + published = publishCompleteHydration(target) + } + + private companion object { + const val DEFAULT_BLOCK_SIZE_BYTES = 1024 * 1024 + } +} + +/** Bounded writable proxy that rejects oversized or reserve-consuming writes before mutation. */ +internal class AndroidWritableFileProxyCallback( + private val staging: File, + private val onReleased: (Throwable?) -> Unit, +) : ProxyFileDescriptorCallback() { + private val random = RandomAccessFile(staging, "rw") + private var released = false + + @Synchronized + override fun onGetSize(): Long = random.length() + + @Synchronized + override fun onRead(offset: Long, requestedSize: Int, data: ByteArray): Int { + requireOpen() + if (offset < 0L || requestedSize < 0 || requestedSize > data.size) { + throw ErrnoException("document writeback read", OsConstants.EINVAL) + } + if (offset >= random.length() || requestedSize == 0) return 0 + val length = minOf(requestedSize.toLong(), random.length() - offset).toInt() + random.seek(offset) + random.readFully(data, 0, length) + return length + } + + @Synchronized + override fun onWrite(offset: Long, requestedSize: Int, data: ByteArray): Int { + requireOpen() + if (offset < 0L || requestedSize < 0 || requestedSize > data.size) { + throw ErrnoException("document writeback write", OsConstants.EINVAL) + } + val end = runCatching { Math.addExact(offset, requestedSize.toLong()) } + .getOrElse { throw ErrnoException("document writeback write", OsConstants.EFBIG) } + if (end > MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES) { + throw ErrnoException("document writeback write", OsConstants.EFBIG) + } + val available = staging.parentFile?.usableSpace?.coerceAtLeast(0L) ?: 0L + if (!androidDocumentWriteFitsCapacity(random.length(), end, available)) { + throw ErrnoException("document writeback write", OsConstants.ENOSPC) + } + if (requestedSize == 0) return 0 + random.seek(offset) + random.write(data, 0, requestedSize) + return requestedSize + } + + @Synchronized + override fun onFsync() { + requireOpen() + random.fd.sync() + } + + @Synchronized + override fun onRelease() { + if (released) return + released = true + val syncFailure = runCatching { random.fd.sync() }.exceptionOrNull() + val closeFailure = runCatching { random.close() }.exceptionOrNull() + if (syncFailure != null && closeFailure != null) syncFailure.addSuppressed(closeFailure) + val failure = syncFailure ?: closeFailure + onReleased(failure) + } + + @Synchronized + fun abort() { + if (released) return + released = true + runCatching(random::close) + } + + private fun requireOpen() { + if (released) throw ErrnoException("document writeback", OsConstants.EBADF) + } +} + +internal fun androidDocumentWriteFitsCapacity( + currentBytes: Long, + writeEnd: Long, + availableBytes: Long, + reserveBytes: Long = MIN_ANDROID_DOCUMENT_FREE_BYTES, +): Boolean { + if (currentBytes < 0L || writeEnd < 0L || availableBytes < 0L || reserveBytes < 0L) return false + if (currentBytes > MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES) return false + if (writeEnd > MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES) return false + val growth = (writeEnd - currentBytes).coerceAtLeast(0L) + return availableBytes >= growth && availableBytes - growth >= reserveBytes +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index b6ba870d1..a45e5ae22 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -7,18 +7,23 @@ import android.os.Handler import android.os.HandlerThread import android.os.OperationCanceledException import android.os.ParcelFileDescriptor +import android.os.storage.StorageManager import android.provider.DocumentsContract import android.provider.DocumentsProvider import android.util.Log -import dev.obiente.nextcloudnative.app.DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES import dev.obiente.nextcloudnative.app.NextcloudFile import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File +import java.io.FileOutputStream import java.io.FileNotFoundException import java.net.URI +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.util.concurrent.Executors +import java.util.concurrent.ConcurrentHashMap +import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -31,6 +36,7 @@ import kotlinx.coroutines.runBlocking class NextcloudDocumentsProvider : DocumentsProvider() { private lateinit var services: AndroidNextcloudServices private lateinit var offline: AndroidFileOfflineRepository + private lateinit var virtualFiles: AndroidVirtualFileCache private lateinit var webDav: NextcloudDocumentWebDav @Volatile @@ -38,8 +44,10 @@ class NextcloudDocumentsProvider : DocumentsProvider() { override fun onCreate(): Boolean { val providerContext = context ?: return false + cleanupIncompleteAndroidDocumentWritebacks(providerContext) services = AndroidNextcloudServices(providerContext) offline = AndroidFileOfflineRepository(providerContext) + virtualFiles = AndroidVirtualFileCache(providerContext) webDav = NextcloudDocumentWebDav( cloudMutationsAllowed = providerContext.cloudMutationGate(), ) @@ -165,35 +173,90 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) + val file = runCatching { findDocument(session, account, reference.path) } + .getOrElse { failure -> + if (mode == "r") { + virtualFiles.acquire(session, reference.path)?.let { lease -> + signal?.throwIfCanceled() + return openVirtualFileLease(lease) + } + } + throw failure + } if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") - if ((file.size ?: 0L) > DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES) { - throw FileNotFoundException("This file is larger than the current provider limit.") + if (mode != "r") return openWritableDocument(session, account, file, mode, signal) + + file.etag?.takeIf(String::isNotBlank)?.let { etag -> + virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> + signal?.throwIfCanceled() + return openVirtualFileLease(lease) + } } - if (mode != "r") return openWritableDocument(session, account, file, mode, signal) + return openVirtualFileProxy(session, account, file, signal) + } - val (readSide, writeSide) = ParcelFileDescriptor.createReliablePipe() - READ_EXECUTOR.execute { - try { - ParcelFileDescriptor.AutoCloseOutputStream(writeSide).use { output -> - webDav.readFile( - session = session, - userId = account.userId, - path = reference.path, - destination = output, - maximumBytes = DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES, - cancellation = signal.asDocumentCancellation(), - ) + private fun openVirtualFileProxy( + session: NextcloudSession, + account: ResolvedAccount, + file: NextcloudFile, + signal: CancellationSignal?, + ): ParcelFileDescriptor { + val size = file.size ?: throw FileNotFoundException( + "Nextcloud did not provide a file size for seekable access.", + ) + val etag = file.etag?.takeIf(String::isNotBlank) ?: throw FileNotFoundException( + "Nextcloud did not provide an ETag for generation-safe access.", + ) + if (size == 0L) { + var empty = virtualFiles.createHydrationStagingFile() + if (runCatching { virtualFiles.publishHydration(session, file, empty) }.getOrDefault(false)) { + virtualFiles.acquire(session, file.path, expectedRemoteEtag = etag)?.let { lease -> + return openVirtualFileLease(lease) } - } catch (cancelled: OperationCanceledException) { - runCatching { writeSide.closeWithError("Read cancelled") } - } catch (failure: Throwable) { - Log.w(LOG_TAG, "System document read failed", failure) - runCatching { writeSide.closeWithError("Could not read the Nextcloud document") } } + if (!empty.exists()) empty = virtualFiles.createHydrationStagingFile() + return ParcelFileDescriptor.open(empty, ParcelFileDescriptor.MODE_READ_ONLY, WRITE_HANDLER) { + virtualFiles.discardHydrationStagingFile(empty) + } + } + val rangeSession = services.openFileRangeSession( + session = session, + userId = account.userId, + path = file.path, + size = size, + expectedEtag = etag, + ) + val staging = virtualFiles.prepareHydration(session, size) + val callback = AndroidVirtualFileProxyCallback( + source = rangeSession, + staging = staging, + publishCompleteHydration = { complete -> + runCatching { virtualFiles.publishHydration(session, file, complete) } + .onFailure { failure -> Log.w(LOG_TAG, "Virtual file cache publish failed", failure) } + .getOrDefault(false) + }, + discardIncompleteHydration = virtualFiles::discardHydrationStagingFile, + ) + signal?.setOnCancelListener(callback::cancel) + return try { + requireNotNull(context?.getSystemService(StorageManager::class.java)) + .openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, callback, WRITE_HANDLER) + } catch (failure: Throwable) { + callback.onRelease() + throw failure } - return readSide + } + + private fun openVirtualFileLease(lease: AndroidVirtualFileLease): ParcelFileDescriptor = try { + ParcelFileDescriptor.open( + lease.content, + ParcelFileDescriptor.MODE_READ_ONLY, + WRITE_HANDLER, + ) { lease.release() } + } catch (failure: Throwable) { + lease.release() + throw failure } override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { @@ -228,7 +291,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) if (destination == reference.path) return documentId val etag = requireMutationEtag(file) - mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } + withNoActiveAndroidDocumentWriteback(session, reference.path, destination) { + mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } + } notifyMove(session, reference.path, destination) return NextcloudDocumentIds.documentId(session, destination) } @@ -239,14 +304,16 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") val account = resolveAccount(session) val file = findDocument(session, account, reference.path) - mutationCall { - webDav.delete( - session, - account.userId, - reference.path, - requireMutationEtag(file), - isDirectory = file.isDirectory, - ) + withNoActiveAndroidDocumentWriteback(session, reference.path) { + mutationCall { + webDav.delete( + session, + account.userId, + reference.path, + requireMutationEtag(file), + isDirectory = file.isDirectory, + ) + } } notifyDocumentChanged(session, reference.path) } @@ -269,8 +336,10 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val file = findDocument(session, account, source.path) val destination = childPath(targetParent.path, file.name) if (destination == source.path) return sourceDocumentId - mutationCall { - webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) + withNoActiveAndroidDocumentWriteback(session, source.path, destination) { + mutationCall { + webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) + } } notifyMove(session, source.path, destination) return NextcloudDocumentIds.documentId(session, destination) @@ -283,50 +352,86 @@ class NextcloudDocumentsProvider : DocumentsProvider() { mode: String, signal: CancellationSignal?, ): ParcelFileDescriptor { - val expectedEtag = requireMutationEtag(file) - val staging = createLocalStagingFile() + reserveAndroidDocumentWritebackPath(session, file.path) + val recovered: AndroidDocumentPendingWriteback? + val writeback: AndroidDocumentPendingWriteback try { - if (mode !in TRUNCATING_OPEN_MODES) { + recovered = claimAndroidDocumentPendingWriteback(context, session, file.path) + if (recovered?.conflict == true) { + recovered.releaseActive() + error("This retained local edit conflicts with a newer Nextcloud generation.") + } + writeback = recovered ?: createDurableWriteback(session, file, requireMutationEtag(file)) + } catch (failure: Throwable) { + releaseAndroidDocumentWritebackPath(session, file.path) + throw failure + } + val expectedEtag = writeback.expectedRemoteEtag + val staging = writeback.staging + try { + if (recovered == null && mode !in TRUNCATING_OPEN_MODES) { + val remoteSize = file.size ?: throw FileNotFoundException( + "Nextcloud did not provide a file size for safe editable staging.", + ) + requireAndroidDocumentWritebackCapacity( + remoteSize = remoteSize, + availableBytes = staging.parentFile?.usableSpace ?: 0L, + ) staging.outputStream().use { output -> webDav.readFile( session = session, userId = account.userId, path = file.path, destination = output, - maximumBytes = DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES, + maximumBytes = MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES, cancellation = signal.asDocumentCancellation(), ) } } signal?.throwIfCanceled() - return ParcelFileDescriptor.open( - staging, - descriptorMode(mode), - WRITE_HANDLER, - ) { closeError -> - if (closeError != null) { - retainFailedStaging(staging, file.name, closeError) - return@open + if (recovered == null) writeback.markReadyAndActive() + if (mode in TRUNCATING_OPEN_MODES) { + java.io.RandomAccessFile(staging, "rw").use { random -> + random.setLength(0L) + random.fd.sync() } + } + val callback = AndroidWritableFileProxyCallback(staging) { closeError -> try { - check(staging.length() <= DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES) { - "The edited file exceeds the ${formatByteLimit(DEFAULT_FILE_DOWNLOAD_LIMIT_BYTES)} limit." + if (closeError != null) { + retainFailedWriteback(writeback, closeError) + } else { + requireAndroidDocumentStagedWritebackCapacity( + stagedBytes = staging.length(), + availableBytes = staging.parentFile?.usableSpace ?: 0L, + ) + webDav.replaceFileAtomically( + session = session, + userId = account.userId, + path = writeback.remotePath, + source = staging, + expectedEtag = expectedEtag, + ) + writeback.complete() + notifyDocumentChanged(session, writeback.remotePath) } - webDav.replaceFileAtomically( - session = session, - userId = account.userId, - path = file.path, - source = staging, - expectedEtag = expectedEtag, - ) - staging.delete() - notifyDocumentChanged(session, file.path) } catch (failure: Throwable) { - retainFailedStaging(staging, file.name, failure) + retainFailedWriteback(writeback, failure) + } finally { + writeback.releaseActive() } } + return try { + requireNotNull(context?.getSystemService(StorageManager::class.java)) + .openProxyFileDescriptor(descriptorMode(mode), callback, WRITE_HANDLER) + } catch (failure: Throwable) { + callback.abort() + throw failure + } } catch (failure: Throwable) { - staging.delete() + if (writeback.manifest.isFile) { + if (recovered == null) writeback.discard() else writeback.releaseActive() + } throw failure } } @@ -347,19 +452,71 @@ class NextcloudDocumentsProvider : DocumentsProvider() { return File.createTempFile("document-", ".stage", directory) } - private fun retainFailedStaging(staging: File, displayName: String, failure: Throwable) { - val providerContext = context - val recovery = providerContext?.let { File(it.filesDir, RECOVERY_DIRECTORY).apply { mkdirs() } } - val safeDisplayName = displayName.replace(Regex("[^a-zA-Z0-9._-]"), "_").take(80).ifBlank { "document" } - val retained = recovery?.takeIf(File::isDirectory)?.let { directory -> - File(directory, "${System.currentTimeMillis()}-$safeDisplayName.stage") + private fun createDurableWriteback( + session: NextcloudSession, + file: NextcloudFile, + expectedEtag: String, + ): AndroidDocumentPendingWriteback { + val providerContext = requireNotNull(context) { "Provider context is unavailable." } + val recovery = File(providerContext.filesDir, RECOVERY_DIRECTORY).apply { mkdirs() } + check(recovery.isDirectory) { "Could not prepare document recovery storage." } + val staging = File.createTempFile("writeback-", ".stage", recovery) + val manifest = File(recovery, staging.name + ".json") + try { + val payload = JSONObject() + .put("version", 1) + .put("account", NextcloudDocumentIds.accountKey(session)) + .put("path", file.path) + .put("etag", expectedEtag) + .put("displayName", file.name) + .put("stage", staging.name) + .put("startedAt", System.currentTimeMillis()) + .put("ready", false) + .toString().encodeToByteArray() + check(payload.size <= MAX_WRITEBACK_MANIFEST_BYTES) + val temporary = File.createTempFile("manifest-", ".tmp", recovery) + try { + FileOutputStream(temporary).use { output -> + output.write(payload) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + manifest.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + manifest.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } finally { + temporary.delete() + } + return AndroidDocumentPendingWriteback( + staging = staging, + manifest = manifest, + accountId = NextcloudDocumentIds.accountKey(session), + remotePath = file.path, + expectedRemoteEtag = expectedEtag, + ) + } catch (failure: Throwable) { + staging.delete() + manifest.delete() + throw failure } - val wasRetained = retained != null && staging.renameTo(retained) - if (!wasRetained) staging.delete() + } + + private fun retainFailedWriteback(writeback: AndroidDocumentPendingWriteback, failure: Throwable) { + val wasRetained = writeback.staging.isFile && writeback.manifest.isFile Log.e( LOG_TAG, - if (wasRetained) "Document commit failed; local staged content was retained." - else "Document commit failed and local staging could not be retained.", + if (wasRetained) "Document commit failed; local staged content and recovery metadata were retained." + else "Document commit failed and durable recovery storage is incomplete.", failure, ) } @@ -412,6 +569,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } private fun notifyDocumentChanged(session: NextcloudSession, path: String) { + runCatching { virtualFiles.invalidate(session, path) } + .onFailure { failure -> Log.w(LOG_TAG, "Could not invalidate virtual file content", failure) } val resolver = context?.contentResolver ?: return resolver.notifyChange( DocumentsContract.buildDocumentUri( @@ -429,8 +588,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } - private fun formatByteLimit(bytes: Long): String = "${bytes / (1024 * 1024)} MiB" - private fun MatrixCursor.addDocumentRow(session: NextcloudSession, file: NextcloudFile?) { val isDirectory = file?.isDirectory ?: true val path = file?.path.orEmpty() @@ -502,7 +659,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } private fun findDocumentWithOfflineFallback(session: NextcloudSession, path: String): NextcloudFile { - val cached = offline.availableEntry(session, path) + val cached = offline.availableEntry(session, path) ?: virtualFiles.cachedEntry(session, path) return runCatching { findDocument(session, resolveAccount(session), path) } .getOrElse { failure -> cached ?: throw FileNotFoundException("The requested Nextcloud document was not found.").also { @@ -543,13 +700,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { const val LOG_TAG = "NextcloudDocuments" const val STAGING_DIRECTORY = "documents-staging" const val RECOVERY_DIRECTORY = "documents-recovery" + const val MAX_WRITEBACK_MANIFEST_BYTES = 64 * 1024 val SUPPORTED_OPEN_MODES = setOf("r", "w", "wt", "wa", "rw", "rwt") val TRUNCATING_OPEN_MODES = setOf("wt", "rwt") val WRITE_THREAD = HandlerThread("nextcloud-document-commit").apply { start() } val WRITE_HANDLER = Handler(WRITE_THREAD.looper) - val READ_EXECUTOR = Executors.newFixedThreadPool(2) { runnable -> - Thread(runnable, "nextcloud-document-read").apply { isDaemon = true } - } val DEFAULT_ROOT_PROJECTION = arrayOf( DocumentsContract.Root.COLUMN_ROOT_ID, DocumentsContract.Root.COLUMN_DOCUMENT_ID, @@ -569,3 +724,265 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } } + +internal const val MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES = 2L * 1024L * 1024L * 1024L +internal const val MIN_ANDROID_DOCUMENT_FREE_BYTES = 512L * 1024L * 1024L + +internal fun requireAndroidDocumentWritebackCapacity(remoteSize: Long, availableBytes: Long) { + require(remoteSize >= 0L && availableBytes >= 0L) + require(remoteSize <= MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES) { + "The file is too large for editable Android staging." + } + require(remoteSize <= (availableBytes - MIN_ANDROID_DOCUMENT_FREE_BYTES).coerceAtLeast(0L)) { + "There is not enough free space to stage this edit safely." + } +} + +internal fun requireAndroidDocumentStagedWritebackCapacity(stagedBytes: Long, availableBytes: Long) { + require(stagedBytes >= 0L && availableBytes >= 0L) + require(stagedBytes <= MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES) { + "The edited file exceeds the Android writeback limit." + } + require(availableBytes >= MIN_ANDROID_DOCUMENT_FREE_BYTES) { + "There is not enough free space to retain this edit safely." + } +} + +internal data class AndroidDocumentPendingWriteback( + val staging: File, + val manifest: File, + val accountId: String, + val remotePath: String, + val expectedRemoteEtag: String, + val conflict: Boolean = false, +) { + init { + require(accountId.isNotBlank()) + require(remotePath.isNotBlank() && remotePath.split('/').none { it.isEmpty() || it == "." || it == ".." }) + require(expectedRemoteEtag.isNotBlank() && '\r' !in expectedRemoteEtag && '\n' !in expectedRemoteEtag) + require(staging.isFile && manifest.isFile) + } + + fun markReadyAndActive() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val payload = JSONObject(manifest.readText()).put("ready", true).toString().encodeToByteArray() + val temporary = File.createTempFile("manifest-", ".tmp", manifest.parentFile) + try { + FileOutputStream(temporary).use { output -> + output.write(payload) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + manifest.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary.toPath(), manifest.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += manifest.activeWritebackKey() + } finally { + temporary.delete() + } + } + + fun markConflict(observedRemoteEtag: String?) = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val data = JSONObject(manifest.readText()) + .put("conflict", true) + .put("observedEtag", observedRemoteEtag ?: JSONObject.NULL) + val payload = data.toString().encodeToByteArray() + require(payload.size <= 64 * 1024) + val temporary = File.createTempFile("manifest-", ".tmp", manifest.parentFile) + try { + FileOutputStream(temporary).use { output -> + output.write(payload) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + manifest.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary.toPath(), manifest.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } finally { + temporary.delete() + } + } + + fun complete() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + staging.delete() + manifest.delete() + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey() + ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath() + } + + fun discard() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + manifest.delete() + staging.delete() + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey() + ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath() + } + + fun releaseActive() = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS -= manifest.activeWritebackKey() + ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activeWritebackPath() + } + + private fun activeWritebackPath() = ActiveAndroidDocumentWritebackPath(accountId, remotePath) +} + +internal fun androidDocumentPendingWritebackCount(context: android.content.Context, session: NextcloudSession): Int { + return androidDocumentPendingWritebacks(context, session).size +} + +internal fun androidDocumentPendingWritebacks( + context: android.content.Context, + session: NextcloudSession, +): List = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val root = File(context.filesDir, "documents-recovery") + if (!root.isDirectory) return emptyList() + val account = NextcloudDocumentIds.accountKey(session) + return root.listFiles().orEmpty().mapNotNull { manifest -> + parseAndroidDocumentWriteback(root, manifest, account) + }.filterNot { writeback -> + writeback.manifest.activeWritebackKey() in ACTIVE_ANDROID_DOCUMENT_WRITEBACKS + }.sortedBy { writeback -> writeback.manifest.lastModified() } +} + +internal fun androidDocumentPendingWriteback( + context: android.content.Context?, + session: NextcloudSession, + remotePath: String, +): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val root = context?.let { File(it.filesDir, "documents-recovery") } ?: return null + if (!root.isDirectory) return null + val account = NextcloudDocumentIds.accountKey(session) + return root.listFiles().orEmpty().asSequence() + .mapNotNull { manifest -> parseAndroidDocumentWriteback(root, manifest, account) } + .filter { writeback -> writeback.remotePath == remotePath } + .filterNot { writeback -> + writeback.manifest.activeWritebackKey() in ACTIVE_ANDROID_DOCUMENT_WRITEBACKS + } + .maxByOrNull { writeback -> writeback.manifest.lastModified() } +} + +private fun claimAndroidDocumentPendingWriteback( + context: android.content.Context?, + session: NextcloudSession, + remotePath: String, +): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + androidDocumentPendingWriteback(context, session, remotePath)?.also { writeback -> + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += writeback.manifest.activeWritebackKey() + } +} + +internal fun claimAndroidDocumentPendingWritebackForRecovery( + context: android.content.Context, + session: NextcloudSession, + remotePath: String, +): AndroidDocumentPendingWriteback? = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val activePath = ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath) + if (!ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.add(activePath)) return null + val pending = androidDocumentPendingWriteback(context, session, remotePath) + if (pending == null) { + ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= activePath + return null + } + ACTIVE_ANDROID_DOCUMENT_WRITEBACKS += pending.manifest.activeWritebackKey() + pending +} + +private fun reserveAndroidDocumentWritebackPath(session: NextcloudSession, remotePath: String) = + synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val active = ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath) + check(ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.add(active)) { + "This document already has an active local edit." + } + } + +private fun releaseAndroidDocumentWritebackPath(session: NextcloudSession, remotePath: String) = + synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS -= + ActiveAndroidDocumentWritebackPath(NextcloudDocumentIds.accountKey(session), remotePath) + } + +private fun withNoActiveAndroidDocumentWriteback( + session: NextcloudSession, + vararg remotePaths: String, + operation: () -> T, +): T = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val accountId = NextcloudDocumentIds.accountKey(session) + check(ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.none { active -> + active.accountId == accountId && androidDocumentWritebackPathBlocksMutation(active.remotePath, *remotePaths) + }) { "This document cannot be changed while a local edit is still open." } + operation() +} + +internal fun androidDocumentWritebackPathBlocksMutation( + activePath: String, + vararg mutationPaths: String, +): Boolean = mutationPaths.any { path -> activePath == path || activePath.startsWith("$path/") } + +private fun parseAndroidDocumentWriteback( + root: File, + manifest: File, + expectedAccount: String?, +): AndroidDocumentPendingWriteback? = runCatching { + require(manifest.isFile && manifest.name.endsWith(".stage.json") && manifest.length() <= 64 * 1024L) + val data = JSONObject(manifest.readText()) + val stageName = data.getString("stage") + require(data.getInt("version") == 1 && data.optBoolean("ready", false)) + val account = data.getString("account") + require(expectedAccount == null || account == expectedAccount) + require(data.getLong("startedAt") >= 0L) + require(stageName.startsWith("writeback-") && stageName.endsWith(".stage")) + require('/' !in stageName && '\\' !in stageName) + require(manifest.name == "$stageName.json") + val stage = File(root, stageName) + require(stage.isFile) + AndroidDocumentPendingWriteback( + staging = stage, + manifest = manifest, + accountId = account, + remotePath = data.getString("path"), + expectedRemoteEtag = data.getString("etag"), + conflict = data.optBoolean("conflict", false), + ) +}.getOrNull() + +/** Removes writeback transactions that could not reach the close-ready state before process death. */ +internal fun cleanupIncompleteAndroidDocumentWritebacks(context: android.content.Context): Int = + synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + val root = File(context.filesDir, "documents-recovery") + if (!root.isDirectory) return 0 + val files = root.listFiles().orEmpty().filter(File::isFile) + val retainedNames = files.mapNotNull { manifest -> + parseAndroidDocumentWriteback(root, manifest, expectedAccount = null) + }.flatMapTo(hashSetOf()) { writeback -> + listOf(writeback.staging.name, writeback.manifest.name) + } + return files.count { file -> + val owned = + (file.name.startsWith("writeback-") && file.name.endsWith(".stage")) || + (file.name.startsWith("writeback-") && file.name.endsWith(".stage.json")) || + (file.name.startsWith("manifest-") && file.name.endsWith(".tmp")) + owned && file.name !in retainedNames && file.delete() + } + } + +private fun File.activeWritebackKey(): String = absoluteFile.normalize().path + +private data class ActiveAndroidDocumentWritebackPath( + val accountId: String, + val remotePath: String, +) + +private val ANDROID_DOCUMENT_WRITEBACK_LOCK = Any() +private val ACTIVE_ANDROID_DOCUMENT_WRITEBACKS = ConcurrentHashMap.newKeySet() +private val ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS = + ConcurrentHashMap.newKeySet() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index 55ef9a1f1..2c092228d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.FileSyncPriorityRule import java.io.File import java.nio.file.Files import java.util.concurrent.CountDownLatch @@ -95,7 +96,15 @@ class AndroidFileSyncStoreTest { accountId = "account-1", localRootId = "content://documents/tree/primary%3ANotes", remoteRootPath = "Notes", - configuration = FileSyncConfiguration(deviceLabel = "phone"), + configuration = FileSyncConfiguration( + deviceLabel = "phone", + selectedPaths = listOf("Camera"), + ignoredPatterns = listOf("*.part"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + ), + ), ) private fun withTemporaryStore(block: (AndroidFileSyncStore) -> Unit) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt new file mode 100644 index 000000000..f8c707874 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt @@ -0,0 +1,183 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidVirtualFileProxyCallbackTest { + @Test + fun `writeback reconciliation compares remote bytes without replacing the retained stage`() { + val staging = Files.createTempFile("writeback-compare-", ".stage").toFile().apply { + writeText("retained edit") + } + try { + val matching = AndroidDocumentStagingComparator(staging) + matching.write("retained edit".encodeToByteArray()) + assertTrue(matching.matches(staging.length())) + matching.close() + + val different = AndroidDocumentStagingComparator(staging) + different.write("remote change".encodeToByteArray()) + assertFalse(different.matches("remote change".length.toLong())) + different.close() + + assertEquals("retained edit", staging.readText()) + } finally { + staging.delete() + } + } + + @Test + fun `active child writeback blocks parent and exact path mutations`() { + assertTrue( + androidDocumentWritebackPathBlocksMutation( + "Projects/Active/notes.txt", + "Projects/Active", + ), + ) + assertTrue( + androidDocumentWritebackPathBlocksMutation( + "Projects/Active/notes.txt", + "Projects/Active/notes.txt", + ), + ) + assertFalse( + androidDocumentWritebackPathBlocksMutation( + "Projects/Active/notes.txt", + "Projects/Archive", + ), + ) + } + + @Test + fun `writable proxy capacity preserves the limit and free space reserve`() { + assertTrue(androidDocumentWriteFitsCapacity(40L, 50L, 110L, reserveBytes = 100L)) + assertFalse(androidDocumentWriteFitsCapacity(40L, 51L, 110L, reserveBytes = 100L)) + assertFalse( + androidDocumentWriteFitsCapacity( + currentBytes = MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES, + writeEnd = MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES + 1L, + availableBytes = Long.MAX_VALUE, + ), + ) + } + + @Test + fun `writable proxy rejects oversized writes before changing staged bytes`() { + val staging = Files.createTempFile("writable-proxy-", ".stage").toFile().apply { + writeText("retained") + } + var releaseFailure: Throwable? = null + val callback = AndroidWritableFileProxyCallback(staging) { failure -> releaseFailure = failure } + + assertFailsWith { + callback.onWrite(MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES, 1, byteArrayOf(1)) + } + assertEquals("retained", staging.readText()) + + callback.onRelease() + assertEquals(null, releaseFailure) + staging.delete() + } + + @Test + fun `hydration capacity preserves the configured free space reserve`() { + assertTrue(androidHydrationFitsCapacity(sizeBytes = 40L, availableBytes = 140L, reserveBytes = 100L)) + assertFalse(androidHydrationFitsCapacity(sizeBytes = 41L, availableBytes = 140L, reserveBytes = 100L)) + assertFalse(androidHydrationFitsCapacity(sizeBytes = Long.MAX_VALUE, availableBytes = Long.MAX_VALUE, reserveBytes = 1L)) + } + + @Test + fun `seekable reads hydrate aligned blocks and publish only a complete generation`() { + val sourceBytes = "0123456789".encodeToByteArray() + val ranges = mutableListOf>() + var closed = false + val source = NextcloudFileRangeSession( + size = sourceBytes.size.toLong(), + readBlock = { offset, length -> + ranges += offset to length + sourceBytes.copyOfRange(offset.toInt(), offset.toInt() + length) + }, + closeBlock = { closed = true }, + ) + val staging = Files.createTempFile("virtual-proxy-", ".part").toFile() + var published: ByteArray? = null + val callback = AndroidVirtualFileProxyCallback( + source = source, + staging = staging, + blockSizeBytes = 4, + publishCompleteHydration = { file -> + published = file.readBytes() + true + }, + ) + + val middle = ByteArray(2) + assertEquals(2, callback.onRead(5L, middle.size, middle)) + assertContentEquals("56".encodeToByteArray(), middle) + assertEquals(null, published) + + val start = ByteArray(4) + assertEquals(4, callback.onRead(0L, start.size, start)) + assertContentEquals("0123".encodeToByteArray(), start) + assertEquals(null, published) + + val end = ByteArray(2) + assertEquals(2, callback.onRead(8L, end.size, end)) + assertContentEquals("89".encodeToByteArray(), end) + assertContentEquals(sourceBytes, published) + assertEquals(listOf(4L to 4, 0L to 4, 8L to 2), ranges) + + callback.onRelease() + assertTrue(closed) + } + + @Test + fun `uncached large file reads only the exact requested range`() { + val sourceBytes = "abcdefghij".encodeToByteArray() + val ranges = mutableListOf>() + val callback = AndroidVirtualFileProxyCallback( + source = NextcloudFileRangeSession( + size = sourceBytes.size.toLong(), + readBlock = { offset, length -> + ranges += offset to length + sourceBytes.copyOfRange(offset.toInt(), offset.toInt() + length) + }, + ), + staging = null, + publishCompleteHydration = { true }, + blockSizeBytes = 4, + ) + + val destination = ByteArray(3) + assertEquals(3, callback.onRead(6L, destination.size, destination)) + assertContentEquals("ghi".encodeToByteArray(), destination) + assertEquals(listOf(6L to 3), ranges) + callback.onRelease() + } + + @Test + fun `failed publication leaves staging disposable on release`() { + val bytes = "abcd".encodeToByteArray() + val staging = Files.createTempFile("virtual-proxy-failed-", ".part").toFile() + val callback = AndroidVirtualFileProxyCallback( + source = NextcloudFileRangeSession( + size = bytes.size.toLong(), + readBlock = { offset, length -> bytes.copyOfRange(offset.toInt(), offset.toInt() + length) }, + ), + staging = staging, + publishCompleteHydration = { false }, + blockSizeBytes = 4, + ) + + assertEquals(4, callback.onRead(0L, 4, ByteArray(4))) + callback.onRelease() + + assertTrue(!staging.exists()) + } +} diff --git a/changes/unreleased/11-native-file-sync.md b/changes/unreleased/11-native-file-sync.md new file mode 100644 index 000000000..ec48ca8e4 --- /dev/null +++ b/changes/unreleased/11-native-file-sync.md @@ -0,0 +1,7 @@ +category: feature +issue: 11 +pull: none +platforms: android, desktop +user-facing: yes + +Files can now sync selected folders either way with filters and priority rules, recover safely in the background, and expose writable virtual files on Android, Linux, and Windows. A live desktop tray shows progress and conflicts. diff --git a/design-qa-artifacts/filesync/desktop-sync-map-comparison.png b/design-qa-artifacts/filesync/desktop-sync-map-comparison.png new file mode 100644 index 000000000..0d362b4aa Binary files /dev/null and b/design-qa-artifacts/filesync/desktop-sync-map-comparison.png differ diff --git a/design-qa-artifacts/filesync/mobile-setup-comparison.png b/design-qa-artifacts/filesync/mobile-setup-comparison.png new file mode 100644 index 000000000..cae76efc6 Binary files /dev/null and b/design-qa-artifacts/filesync/mobile-setup-comparison.png differ diff --git a/design-qa-artifacts/filesync/tray-activity-comparison.png b/design-qa-artifacts/filesync/tray-activity-comparison.png new file mode 100644 index 000000000..9410242c7 Binary files /dev/null and b/design-qa-artifacts/filesync/tray-activity-comparison.png differ diff --git a/design-qa-artifacts/filesync/vfs-platform-comparison.png b/design-qa-artifacts/filesync/vfs-platform-comparison.png new file mode 100644 index 000000000..2efdc948e Binary files /dev/null and b/design-qa-artifacts/filesync/vfs-platform-comparison.png differ diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index b8adef21c..ec16fc649 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -131,6 +131,9 @@ kotlin { implementation(libs.jse.spi.opus) implementation(libs.jse.spi.mp3) implementation(libs.jse.spi.aac) + implementation("com.github.serceman:jnr-fuse:0.5.8") + implementation("net.java.dev.jna:jna:5.19.1") + implementation("net.java.dev.jna:jna-platform:5.19.1") } } } @@ -245,6 +248,20 @@ tasks.register("captureMarketingScreenshots") { workingDir(rootProject.projectDir) } +tasks.register("captureFileSyncTrayVisualQa") { + group = "verification" + description = "Captures the custom desktop tray popup with isolated synthetic sync activity." + dependsOn(desktopCaptureCompilation.compileTaskProvider) + classpath( + desktopCaptureCompilation.output.allOutputs, + desktopCaptureCompilation.runtimeDependencyFiles, + ) + mainClass.set( + "dev.obiente.nextcloudnative.nativeui.preview.FileSyncTrayVisualQaMainKt", + ) + workingDir(rootProject.projectDir) +} + tasks.register("capturePhotoTimelinePreview") { group = "verification" description = "Renders the phone Photos timeline from isolated synthetic fixtures." diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index c2b675a2b..04bf12fe9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt @@ -2,32 +2,31 @@ package dev.obiente.nextcloudnative.app import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilterChip +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -37,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,6 +52,9 @@ import dev.obiente.nextcloudnative.app.design.NextcloudTheme import dev.obiente.nextcloudnative.app.design.nextcloudCardInteractions import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json @Composable internal fun FileOfflineCenterScreen( @@ -72,13 +75,33 @@ internal fun FileOfflineCenterScreen( var mediaFolderDiscovery by remember(session, userId) { mutableStateOf(null) } var mediaDiscoveryLoading by remember(session, userId) { mutableStateOf(false) } var syncBusyPairId by remember(session, userId) { mutableStateOf(null) } - var pendingLocalRoot by remember(session, userId) { mutableStateOf(null) } - var pendingMediaSuggestion by remember(session, userId) { mutableStateOf(null) } - var pendingRemotePath by remember(session, userId) { mutableStateOf(null) } - var pendingSyncConfiguration by remember(session, userId) { - mutableStateOf(null) + var pendingLocalRootJson by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(null) + } + var pendingMediaSuggestionJson by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(null) + } + var pendingRemotePath by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(null) + } + var pendingSyncConfigurationJson by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(null) + } + var remoteFolderPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(false) + } + var syncSelectionPickerVisible by rememberSaveable(session.serverUrl, session.loginName, userId) { + mutableStateOf(false) + } + val pendingLocalRoot = pendingLocalRootJson?.let { encoded -> + runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() + } + val pendingMediaSuggestion = pendingMediaSuggestionJson?.let { encoded -> + runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() + } + val pendingSyncConfiguration = pendingSyncConfigurationJson?.let { encoded -> + runCatching { fileSyncSetupJson.decodeFromString(encoded) }.getOrNull() } - var remoteFolderPickerVisible by remember(session, userId) { mutableStateOf(false) } var pendingMediaPreview by remember(session, userId) { mutableStateOf(null) } var mediaPreviewLoading by remember(session, userId) { mutableStateOf(false) } var mediaPreviewError by remember(session, userId) { mutableStateOf(null) } @@ -86,6 +109,10 @@ internal fun FileOfflineCenterScreen( var pendingSyncDecision by remember(session, userId) { mutableStateOf(null) } + var virtualStorage by remember(session, userId) { mutableStateOf(null) } + var virtualStorageLoading by remember(session, userId) { mutableStateOf(false) } + var virtualStorageBusy by remember(session, userId) { mutableStateOf(false) } + var virtualStorageSettingsVisible by remember(session, userId) { mutableStateOf(false) } val scope = rememberCoroutineScope() fun runItemAction(item: FileOfflineCenterItem, remove: Boolean) { @@ -153,6 +180,65 @@ internal fun FileOfflineCenterScreen( } } + fun saveVirtualStoragePolicy(policy: VirtualFileCachePolicy) { + if (virtualStorageBusy) return + virtualStorageBusy = true + actionMessage = null + scope.launch { + runCatching { services.saveVirtualFileCachePolicy(session, userId, policy) } + .onSuccess { result -> + actionMessage = result.virtualFileStorageMessage() + if (result is VirtualFileStorageActionResult.Completed) { + virtualStorageSettingsVisible = false + refreshAttempt += 1 + } + } + .onFailure { failure -> + actionMessage = failure.message ?: "Could not save virtual file storage rules." + } + virtualStorageBusy = false + } + } + + fun freeUpVirtualStorage() { + val requested = virtualStorage?.reclaimableBytes ?: return + if (virtualStorageBusy || requested == 0L) return + virtualStorageBusy = true + actionMessage = null + scope.launch { + runCatching { services.freeUpVirtualFileSpace(session, userId, requested) } + .onSuccess { result -> + actionMessage = result.virtualFileStorageMessage() + refreshAttempt += 1 + } + .onFailure { failure -> + actionMessage = failure.message ?: "Could not free virtual file storage." + } + virtualStorageBusy = false + } + } + + fun setVirtualFileProviderActive(active: Boolean) { + if (virtualStorageBusy) return + virtualStorageBusy = true + actionMessage = null + scope.launch { + runCatching { + if (active) { + services.activateVirtualFileProvider(session, userId) + } else { + services.deactivateVirtualFileProvider(session, userId) + } + }.onSuccess { result -> + actionMessage = result.virtualFileStorageMessage() + refreshAttempt += 1 + }.onFailure { failure -> + actionMessage = failure.message ?: "Could not change the virtual file provider." + } + virtualStorageBusy = false + } + } + LaunchedEffect(session, userId, refreshAttempt) { if (userId.isBlank()) { loading = false @@ -169,6 +255,20 @@ internal fun FileOfflineCenterScreen( loading = false } + LaunchedEffect(session, userId, refreshAttempt) { + if (userId.isBlank() || !services.supportsVirtualFileStorage) return@LaunchedEffect + virtualStorageLoading = true + try { + virtualStorage = services.loadVirtualFileStorage(session, userId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + actionMessage = failure.message ?: "Could not load virtual file storage." + } finally { + virtualStorageLoading = false + } + } + LaunchedEffect(session, userId, refreshAttempt) { if (userId.isBlank() || !services.supportsBidirectionalFileSync) return@LaunchedEffect syncLoading = true @@ -232,7 +332,7 @@ internal fun FileOfflineCenterScreen( enabled = fileOfflineRefreshEnabled( loading = loading, mediaDiscoveryLoading = mediaDiscoveryLoading, - actionInProgress = actionKey != null, + actionInProgress = actionKey != null || virtualStorageBusy, ), onClick = { refreshAttempt += 1 }, ) { @@ -245,9 +345,6 @@ internal fun FileOfflineCenterScreen( contentPadding = PaddingValues(NextcloudSpacing.XLarge), verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), ) { - item { - OfflineCenterSummaryCard(snapshot, loading) - } loadError?.let { error -> item { OfflineCenterMessageCard(error, errorTone = true) { @@ -271,13 +368,14 @@ internal fun FileOfflineCenterScreen( scope.launch { runCatching { services.chooseFileSyncLocalRoot() } .onSuccess { selected -> - pendingMediaSuggestion = null - pendingLocalRoot = selected - pendingRemotePath = null - pendingSyncConfiguration = selected?.let { + pendingMediaSuggestionJson = null + pendingLocalRootJson = selected?.let { fileSyncSetupJson.encodeToString(it) } + pendingRemotePath = selected?.let { "" } + pendingSyncConfigurationJson = selected?.let { defaultFileSyncConfiguration(isMediaSuggestion = false) - } - remoteFolderPickerVisible = selected != null + }?.let { fileSyncSetupJson.encodeToString(it) } + remoteFolderPickerVisible = false + syncSelectionPickerVisible = false } .onFailure { failure -> actionMessage = failure.message ?: "Could not select a local folder." @@ -289,11 +387,14 @@ internal fun FileOfflineCenterScreen( if (syncBusyPairId == null) { pendingMediaPreview = null mediaPreviewError = null - pendingMediaSuggestion = suggestion - pendingLocalRoot = suggestion.localRoot - pendingRemotePath = null - pendingSyncConfiguration = defaultFileSyncConfiguration(isMediaSuggestion = true) - remoteFolderPickerVisible = true + pendingMediaSuggestionJson = fileSyncSetupJson.encodeToString(suggestion) + pendingLocalRootJson = fileSyncSetupJson.encodeToString(suggestion.localRoot) + pendingRemotePath = suggestion.suggestedRemoteRootPath + pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString( + defaultFileSyncConfiguration(isMediaSuggestion = true), + ) + remoteFolderPickerVisible = false + syncSelectionPickerVisible = false } }, onRequestMediaPermission = { @@ -309,6 +410,22 @@ internal fun FileOfflineCenterScreen( ) } } + if (services.supportsVirtualFileStorage) { + item { + VirtualFileStorageCard( + snapshot = virtualStorage, + loading = virtualStorageLoading, + busy = virtualStorageBusy, + onManage = { virtualStorageSettingsVisible = true }, + onFreeUp = ::freeUpVirtualStorage, + onActivateProvider = { setVirtualFileProviderActive(true) }, + onDeactivateProvider = { setVirtualFileProviderActive(false) }, + ) + } + } + item { + OfflineCenterSummaryCard(snapshot, loading) + } snapshot?.limitations?.takeIf(List::isNotEmpty)?.let { limitations -> item { Surface( @@ -328,7 +445,7 @@ internal fun FileOfflineCenterScreen( } .forEach { limitation -> Text( - "• $limitation", + "- $limitation", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -403,6 +520,17 @@ internal fun FileOfflineCenterScreen( ) } + if (virtualStorageSettingsVisible) { + virtualStorage?.let { current -> + VirtualFileStoragePolicyDialog( + snapshot = current, + busy = virtualStorageBusy, + onDismiss = { if (!virtualStorageBusy) virtualStorageSettingsVisible = false }, + onSave = ::saveVirtualStoragePolicy, + ) + } + } + val localRootForDestination = pendingLocalRoot if (remoteFolderPickerVisible && localRootForDestination != null) { RemoteFolderPickerDialog( @@ -414,20 +542,53 @@ internal fun FileOfflineCenterScreen( onDismiss = { remoteFolderPickerVisible = false if (pendingRemotePath == null) { - pendingLocalRoot = null - pendingMediaSuggestion = null - pendingSyncConfiguration = null + pendingLocalRootJson = null + pendingMediaSuggestionJson = null + pendingSyncConfigurationJson = null } }, onSelected = { selectedPath -> + if (pendingRemotePath != selectedPath) { + pendingSyncConfiguration?.let { configuration -> + pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString( + configuration.copy(selectedPaths = emptyList()), + ) + } + } pendingRemotePath = selectedPath remoteFolderPickerVisible = false }, ) } + val selectionConfiguration = pendingSyncConfiguration + val selectionRemoteRoot = pendingRemotePath + if ( + syncSelectionPickerVisible && + selectionConfiguration != null && + selectionRemoteRoot != null + ) { + RemoteFileSyncSelectionDialog( + services = services, + session = session, + userId = userId, + remoteRootPath = selectionRemoteRoot, + initialSelection = selectionConfiguration.selectedPaths, + onDismiss = { syncSelectionPickerVisible = false }, + onSelected = { selectedPaths -> + pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString( + selectionConfiguration.copy(selectedPaths = selectedPaths), + ) + syncSelectionPickerVisible = false + }, + ) + } + pendingLocalRoot?.takeIf { - !remoteFolderPickerVisible && pendingRemotePath != null && pendingSyncConfiguration != null + !remoteFolderPickerVisible && + !syncSelectionPickerVisible && + pendingRemotePath != null && + pendingSyncConfiguration != null }?.let { localRoot -> AddFolderSyncDialog( localRoot = localRoot, @@ -440,17 +601,21 @@ internal fun FileOfflineCenterScreen( busy = syncBusyPairId == ADD_PAIR_BUSY_ID, onDismiss = { if (syncBusyPairId == null) { - pendingLocalRoot = null - pendingMediaSuggestion = null + pendingLocalRootJson = null + pendingMediaSuggestionJson = null pendingRemotePath = null - pendingSyncConfiguration = null + pendingSyncConfigurationJson = null pendingMediaPreview = null + syncSelectionPickerVisible = false } }, onChooseDestination = { if (syncBusyPairId == null) remoteFolderPickerVisible = true }, - onConfigurationChanged = { pendingSyncConfiguration = it }, + onChooseSelectedPaths = { + if (syncBusyPairId == null) syncSelectionPickerVisible = true + }, + onConfigurationChanged = { pendingSyncConfigurationJson = fileSyncSetupJson.encodeToString(it) }, onAdd = { if (syncBusyPairId != null) return@AddFolderSyncDialog syncBusyPairId = ADD_PAIR_BUSY_ID @@ -469,11 +634,12 @@ internal fun FileOfflineCenterScreen( }.onSuccess { result -> actionMessage = result.fileSyncCenterMessage() if (result is FileSyncCenterActionResult.Completed) { - pendingLocalRoot = null - pendingMediaSuggestion = null + pendingLocalRootJson = null + pendingMediaSuggestionJson = null pendingRemotePath = null - pendingSyncConfiguration = null + pendingSyncConfigurationJson = null pendingMediaPreview = null + syncSelectionPickerVisible = false refreshAttempt += 1 } }.onFailure { failure -> @@ -560,30 +726,6 @@ internal fun FolderSyncSection( onResolve: (FileSyncPairSummary, FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - "Folder sync", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "Revision-guarded local and Nextcloud folder pairs", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - OutlinedButton(enabled = busyPairId == null, onClick = onAdd) { - Text("Add") - } - } - if (loading && snapshot == null) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } MediaFolderSuggestions( discovery = mediaDiscovery, loading = mediaDiscoveryLoading, @@ -591,23 +733,15 @@ internal fun FolderSyncSection( onOpen = onOpenMediaSuggestion, onRequestPermission = onRequestMediaPermission, ) - val pairs = snapshot?.pairs.orEmpty() - if (!loading && pairs.isEmpty()) { - OfflineCenterMessageCard( - "No folder sync pairs yet. Choose a local folder, then connect it to a folder in Nextcloud Files.", - errorTone = false, - ) - } - pairs.forEach { pair -> - FolderSyncPairCard( - pair = pair, - busy = busyPairId == pair.id, - actionsEnabled = busyPairId == null, - onRun = { onRun(pair) }, - onRemove = { onRemove(pair) }, - onResolve = { conflict, choice -> onResolve(pair, conflict, choice) }, - ) - } + FileSyncWorkspace( + snapshot = snapshot, + loading = loading, + busyPairId = busyPairId, + onAdd = onAdd, + onRun = onRun, + onRemove = onRemove, + onResolve = onResolve, + ) } } @@ -682,8 +816,8 @@ private fun MediaFolderSuggestions( Column(modifier = Modifier.weight(1f)) { Text(suggestion.displayName, style = MaterialTheme.typography.titleMedium) Text( - "${suggestion.kind.readableMediaFolderKind()} · " + - "${suggestion.imageCount} photos · ${suggestion.videoCount} videos", + "${suggestion.kind.readableMediaFolderKind()} | " + + "${suggestion.imageCount} photos | ${suggestion.videoCount} videos", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -781,13 +915,13 @@ private fun FolderSyncPairCard( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - "${pair.configuration.direction.readableSyncDirection()} · " + - "${pair.readyCount} pending · ${pair.runningCount} syncing", + "${pair.configuration.direction.readableSyncDirection()} | " + + "${pair.readyCount} pending | ${pair.runningCount} syncing", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - "${pair.completedCount} completed · ${pair.conflicts.size} conflicts · ${pair.failedCount} failed", + "${pair.completedCount} completed | ${pair.conflicts.size} conflicts | ${pair.failedCount} failed", style = MaterialTheme.typography.bodySmall, color = if (pair.conflicts.size + pair.failedCount > 0) { MaterialTheme.colorScheme.error @@ -796,11 +930,30 @@ private fun FolderSyncPairCard( }, ) Text( - "${pair.configuration.networkPolicy.readableNetworkPolicy()} · " + + "${pair.configuration.networkPolicy.readableNetworkPolicy()} | " + pair.configuration.powerPolicy.readablePowerPolicy(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if ( + pair.configuration.selectedPaths.isNotEmpty() || + pair.configuration.ignoredPatterns.isNotEmpty() || + pair.configuration.priorityRules.isNotEmpty() + ) { + Text( + buildString { + if (pair.configuration.selectedPaths.isEmpty()) append("Whole folder") + else append(pair.configuration.selectedPaths.size).append(" selected path") + .append(if (pair.configuration.selectedPaths.size == 1) "" else "s") + append("; ").append(pair.configuration.ignoredPatterns.size).append(" ignore rule") + .append(if (pair.configuration.ignoredPatterns.size == 1) "" else "s") + append("; ").append(pair.configuration.priorityRules.size).append(" priority group") + .append(if (pair.configuration.priorityRules.size == 1) "" else "s") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } pair.scheduleDescription?.let { schedule -> Text( schedule, @@ -873,7 +1026,7 @@ private fun MediaFolderPreview( preview != null -> { val totalLabel = buildString { append(preview.totalItems).append(if (preview.totalItems == 1) " item" else " items") - append(" · ").append(formatOfflineBytes(preview.totalBytes)) + append(" | ").append(formatOfflineBytes(preview.totalBytes)) } Text(totalLabel, style = MaterialTheme.typography.titleSmall) preview.message?.let { message -> @@ -982,7 +1135,7 @@ private fun MediaFolderPreviewTile(item: MediaSyncFolderPreviewItem) { Text( buildString { append(if (item.mimeType?.startsWith("video/") == true) "Video" else "Photo") - item.sizeBytes?.let { append(" · ").append(formatOfflineBytes(it)) } + item.sizeBytes?.let { append(" | ").append(formatOfflineBytes(it)) } }, maxLines = 1, style = MaterialTheme.typography.bodySmall, @@ -992,7 +1145,7 @@ private fun MediaFolderPreviewTile(item: MediaSyncFolderPreviewItem) { } @Composable -private fun AddFolderSyncDialog( +internal fun AddFolderSyncDialog( localRoot: FileSyncLocalRoot, mediaSuggestion: MediaSyncFolderSuggestion?, remotePath: String, @@ -1003,119 +1156,288 @@ private fun AddFolderSyncDialog( busy: Boolean, onDismiss: () -> Unit, onChooseDestination: () -> Unit, + onChooseSelectedPaths: () -> Unit, onConfigurationChanged: (FileSyncConfiguration) -> Unit, onAdd: () -> Unit, ) { - AlertDialog( - onDismissRequest = { if (!busy) onDismiss() }, - title = { Text("Add folder sync") }, - text = { - Column( - modifier = Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + GuidedAddFolderSyncDialog( + localRoot = localRoot, + mediaSuggestion = mediaSuggestion, + remotePath = remotePath, + configuration = configuration, + mediaPreview = mediaPreview, + mediaPreviewLoading = mediaPreviewLoading, + mediaPreviewError = mediaPreviewError, + busy = busy, + onDismiss = onDismiss, + onChooseDestination = onChooseDestination, + onChooseSelectedPaths = onChooseSelectedPaths, + onConfigurationChanged = onConfigurationChanged, + onAdd = onAdd, + ) +} + +private fun defaultFileSyncConfiguration(isMediaSuggestion: Boolean): FileSyncConfiguration = + FileSyncConfiguration( + direction = if (isMediaSuggestion) FileSyncDirection.UploadOnly else FileSyncDirection.Bidirectional, + conflictPolicy = FileSyncConflictPolicy.Ask, + deletionPolicy = FileSyncDeletionPolicy.Ask, + deviceLabel = "mobile", + networkPolicy = FileSyncNetworkPolicy.AnyConnection, + powerPolicy = FileSyncPowerPolicy.BatteryNotLow, + ) + +private val fileSyncSetupJson = Json { + encodeDefaults = true +} + +internal fun isMediaFolderPreviewReady( + suggestion: MediaSyncFolderSuggestion?, + preview: MediaSyncFolderPreview?, +): Boolean = + suggestion == null || + ( + preview != null && + preview.access == MediaSyncFolderAccess.FullLibrary && + preview.state in setOf( + MediaSyncFolderPreviewState.Available, + MediaSyncFolderPreviewState.Changed, + ) + ) + +@Composable +internal fun VirtualFileStorageCard( + snapshot: VirtualFileStorageSnapshot?, + loading: Boolean, + busy: Boolean, + onManage: () -> Unit, + onFreeUp: () -> Unit, + onActivateProvider: () -> Unit, + onDeactivateProvider: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Large), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, ) { - Text("Local folder: ${mediaSuggestion?.relativePath ?: localRoot.displayName}") - if (mediaSuggestion != null) { - MediaFolderPreview( - suggestion = mediaSuggestion, - preview = mediaPreview, - loading = mediaPreviewLoading, - error = mediaPreviewError, + Column(modifier = Modifier.weight(1f)) { + Text("Virtual files", style = MaterialTheme.typography.titleMedium) + Text( + when (snapshot?.integration) { + VirtualFilePlatformIntegration.AndroidDocumentsProvider -> + "Browse everything in System Files. Content downloads only when opened." + VirtualFilePlatformIntegration.LinuxFilesystemMount -> + "Browse placeholders in your Linux file manager. Content downloads when opened." + VirtualFilePlatformIntegration.InAppOnDemandCache -> + "Files opened in Nextcloud Native are kept in a managed on-demand cache." + VirtualFilePlatformIntegration.WindowsCloudFiles -> + "Browse everything in File Explorer. Files download when opened and local edits sync back." + VirtualFilePlatformIntegration.AppleFileProvider -> + "Files hydrate through the system File Provider." + null -> "Loading on-demand storage status..." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Surface( - modifier = Modifier.fillMaxWidth(), - color = NextcloudTheme.colors.appTile, - shape = RoundedCornerShape(NextcloudRadii.Small), - ) { - Column( - modifier = Modifier.padding(NextcloudSpacing.Medium), - verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + if (loading || busy) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(999.dp), ) { - Text("Nextcloud destination", style = MaterialTheme.typography.labelLarge) Text( - if (remotePath.isEmpty()) "Files root" else "/$remotePath", - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + when (snapshot?.support) { + VirtualFileStorageSupport.Available -> "System integrated" + VirtualFileStorageSupport.CacheOnly -> "App cache" + VirtualFileStorageSupport.Unsupported -> "Unavailable" + null -> "Checking" + }, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) - OutlinedButton(enabled = !busy, onClick = onChooseDestination) { - Text("Choose another folder") - } } } - Text("Direction", style = MaterialTheme.typography.labelLarge) - val directionOptions = if (mediaSuggestion == null) { - FileSyncDirection.entries - } else { - listOf(FileSyncDirection.UploadOnly) - } - directionOptions.forEach { option -> - FilterChip( - selected = configuration.direction == option, - onClick = { onConfigurationChanged(configuration.copy(direction = option)) }, - label = { Text(option.readableSyncDirection()) }, - ) - } - if (mediaSuggestion != null) { - Text( - "Detected media folders are upload-only. Nextcloud never writes into this local folder.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Text("When both copies changed", style = MaterialTheme.typography.labelLarge) - FileSyncConflictPolicy.entries.forEach { option -> - FilterChip( - selected = configuration.conflictPolicy == option, - onClick = { onConfigurationChanged(configuration.copy(conflictPolicy = option)) }, - label = { Text(option.readableConflictPolicy()) }, + } + + if (snapshot != null) { + val maximum = snapshot.policy.maximumCacheBytes + if (maximum != null) { + LinearProgressIndicator( + progress = { + (snapshot.cachedBytes.toDouble() / maximum.toDouble()) + .coerceIn(0.0, 1.0) + .toFloat() + }, + modifier = Modifier.fillMaxWidth().height(6.dp), ) } - Text("When a file was deleted", style = MaterialTheme.typography.labelLarge) - FileSyncDeletionPolicy.entries.forEach { option -> - FilterChip( - selected = configuration.deletionPolicy == option, - onClick = { onConfigurationChanged(configuration.copy(deletionPolicy = option)) }, - label = { Text(option.readableDeletionPolicy()) }, + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + VirtualFileStorageMetric( + label = "Cached", + value = formatVirtualFileBytes(snapshot.cachedBytes), + modifier = Modifier.weight(1f), ) - } - Text("Connection", style = MaterialTheme.typography.labelLarge) - FileSyncNetworkPolicy.entries.forEach { option -> - FilterChip( - selected = configuration.networkPolicy == option, - onClick = { onConfigurationChanged(configuration.copy(networkPolicy = option)) }, - label = { Text(option.readableNetworkPolicy()) }, + VirtualFileStorageMetric( + label = "Pinned", + value = formatVirtualFileBytes(snapshot.pinnedBytes), + modifier = Modifier.weight(1f), ) - } - Text("Power", style = MaterialTheme.typography.labelLarge) - FileSyncPowerPolicy.entries.forEach { option -> - FilterChip( - selected = configuration.powerPolicy == option, - onClick = { onConfigurationChanged(configuration.copy(powerPolicy = option)) }, - label = { Text(option.readablePowerPolicy()) }, + VirtualFileStorageMetric( + label = "Free", + value = snapshot.availableFreeBytes?.let(::formatVirtualFileBytes) ?: "Unknown", + modifier = Modifier.weight(1f), ) } - OutlinedTextField( - value = configuration.deviceLabel, - onValueChange = { - onConfigurationChanged(configuration.copy(deviceLabel = it.take(128))) + Text( + if (snapshot.policy.automaticCleanup) { + buildString { + append("Auto cleanup keeps at least ") + append(formatVirtualFileBytes(snapshot.policy.minimumFreeSpaceBytes)) + append(" free") + snapshot.policy.unusedFileAgeMillis?.let { age -> + append(" and removes unused cached files after ") + append(formatVirtualFileAge(age)) + } + append(". Pins and active work are always kept.") + } + } else { + "Automatic cleanup is off. Pins and active work are always kept." }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Device label for conflict copies") }, - singleLine = true, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (snapshot.providerState != VirtualFileProviderState.NotApplicable) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + when (snapshot.providerState) { + VirtualFileProviderState.Active -> "Available in your file manager" + VirtualFileProviderState.Inactive -> "File-manager integration is off" + VirtualFileProviderState.Starting -> "Starting file-manager integration" + VirtualFileProviderState.NeedsAttention -> "File-manager integration needs attention" + VirtualFileProviderState.NotApplicable -> "" + }, + style = MaterialTheme.typography.labelLarge, + ) + snapshot.providerLocation?.let { location -> + Text( + location, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (snapshot.pendingWritebackCount > 0) { + Text( + "${snapshot.pendingWritebackCount} local edit(s) are retained for recovery.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + if (snapshot.providerState == VirtualFileProviderState.Active) { + OutlinedButton(enabled = !busy, onClick = onDeactivateProvider) { + Text("Disconnect from file manager") + } + } else { + Button(enabled = !busy, onClick = onActivateProvider) { + Text("Connect to file manager") + } + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(enabled = !busy, onClick = onManage) { Text("Manage storage") } + OutlinedButton( + enabled = !busy && snapshot.reclaimableBytes > 0L, + onClick = onFreeUp, + ) { + Text( + if (snapshot.reclaimableBytes > 0L) { + "Free up ${formatVirtualFileBytes(snapshot.reclaimableBytes)}" + } else { + "Nothing to free" + }, + ) + } + } } + } + } +} + +@Composable +private fun VirtualFileStorageMetric( + label: String, + value: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +@Composable +internal fun VirtualFileStoragePolicyDialog( + snapshot: VirtualFileStorageSnapshot, + busy: Boolean, + onDismiss: () -> Unit, + onSave: (VirtualFileCachePolicy) -> Unit, +) { + var policy by remember(snapshot.policy) { mutableStateOf(snapshot.policy) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Virtual file storage") }, + text = { + VirtualFileStoragePolicyEditor( + snapshot = snapshot, + busy = busy, + policy = policy, + onPolicyChanged = { policy = it }, + ) }, confirmButton = { - Button( - enabled = !busy && - configuration.deviceLabel.isNotBlank() && - isMediaFolderPreviewReady(mediaSuggestion, mediaPreview), - onClick = onAdd, - ) { - if (busy) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - else Text("Add sync") + Button(enabled = !busy, onClick = { onSave(policy) }) { + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Save rules") + } } }, dismissButton = { @@ -1124,29 +1446,179 @@ private fun AddFolderSyncDialog( ) } -private fun defaultFileSyncConfiguration(isMediaSuggestion: Boolean): FileSyncConfiguration = - FileSyncConfiguration( - direction = if (isMediaSuggestion) FileSyncDirection.UploadOnly else FileSyncDirection.Bidirectional, - conflictPolicy = FileSyncConflictPolicy.Ask, - deletionPolicy = FileSyncDeletionPolicy.Ask, - deviceLabel = "mobile", - networkPolicy = FileSyncNetworkPolicy.AnyConnection, - powerPolicy = FileSyncPowerPolicy.BatteryNotLow, - ) - -internal fun isMediaFolderPreviewReady( - suggestion: MediaSyncFolderSuggestion?, - preview: MediaSyncFolderPreview?, -): Boolean = - suggestion == null || - ( - preview != null && - preview.access == MediaSyncFolderAccess.FullLibrary && - preview.state in setOf( - MediaSyncFolderPreviewState.Available, - MediaSyncFolderPreviewState.Changed, +@Composable +internal fun VirtualFileStoragePolicyEditor( + snapshot: VirtualFileStorageSnapshot, + busy: Boolean, + policy: VirtualFileCachePolicy, + onPolicyChanged: (VirtualFileCachePolicy) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + LazyColumn( + modifier = modifier, + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + item { + Text( + "Opened files hydrate into the local cache. Pinned offline files, open files, " + + "uploads, edits, and conflicts are never removed automatically.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Auto free up space", style = MaterialTheme.typography.titleSmall) + Text( + "Apply the limits below in the background.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = policy.automaticCleanup, + enabled = !busy, + onCheckedChange = { enabled -> + onPolicyChanged(policy.copy(automaticCleanup = enabled)) + }, ) - ) + } + } + item { + VirtualFilePolicyChoice( + title = "Cache limit", + subtitle = "Automatic content can use up to this much space.", + options = VIRTUAL_CACHE_SIZE_OPTIONS, + selected = policy.maximumCacheBytes, + enabled = !busy, + label = { value -> value?.let(::formatVirtualFileBytes) ?: "No limit" }, + onSelected = { selected -> onPolicyChanged(policy.copy(maximumCacheBytes = selected)) }, + ) + } + item { + VirtualFilePolicyChoice( + title = "Always keep free", + subtitle = "Cleanup starts before device storage drops below this reserve.", + options = VIRTUAL_FREE_SPACE_OPTIONS, + selected = policy.minimumFreeSpaceBytes, + enabled = !busy, + label = ::formatVirtualFileBytes, + onSelected = { selected -> onPolicyChanged(policy.copy(minimumFreeSpaceBytes = selected)) }, + ) + } + item { + VirtualFilePolicyChoice( + title = "Remove if unused", + subtitle = "Recently opened automatic files stay close at hand.", + options = VIRTUAL_UNUSED_AGE_OPTIONS, + selected = policy.unusedFileAgeMillis, + enabled = !busy, + label = { value -> value?.let(::formatVirtualFileAge) ?: "Never" }, + onSelected = { selected -> onPolicyChanged(policy.copy(unusedFileAgeMillis = selected)) }, + ) + } + item { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Text( + "Currently ${formatVirtualFileBytes(snapshot.cachedBytes)} cached, " + + "${formatVirtualFileBytes(snapshot.reclaimableBytes)} reclaimable, and " + + "${formatVirtualFileBytes(snapshot.pinnedBytes)} pinned.", + modifier = Modifier.padding(NextcloudSpacing.Medium), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + +@Composable +private fun VirtualFilePolicyChoice( + title: String, + subtitle: String, + options: List, + selected: T, + enabled: Boolean, + label: (T) -> String, + onSelected: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box { + OutlinedButton(enabled = enabled, onClick = { expanded = true }) { + Text(label(selected), maxLines = 1) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + if (option == selected) "${label(option)} (selected)" else label(option), + ) + }, + onClick = { + expanded = false + onSelected(option) + }, + ) + } + } + } + } +} + +private fun formatVirtualFileAge(ageMillis: Long): String { + val days = ageMillis / (24L * 60L * 60L * 1_000L) + return when { + days == 30L -> "1 month" + days % 30L == 0L -> "${days / 30L} months" + days == 1L -> "1 day" + else -> "$days days" + } +} + +private val VIRTUAL_CACHE_SIZE_OPTIONS = listOf( + 5L * 1024L * 1024L * 1024L, + 10L * 1024L * 1024L * 1024L, + 20L * 1024L * 1024L * 1024L, + 50L * 1024L * 1024L * 1024L, + null, +) +private val VIRTUAL_FREE_SPACE_OPTIONS = listOf( + 2L * 1024L * 1024L * 1024L, + 5L * 1024L * 1024L * 1024L, + 10L * 1024L * 1024L * 1024L, + 20L * 1024L * 1024L * 1024L, +) +private val VIRTUAL_UNUSED_AGE_OPTIONS = listOf( + 7L * 24L * 60L * 60L * 1_000L, + 30L * 24L * 60L * 60L * 1_000L, + 90L * 24L * 60L * 60L * 1_000L, + null, +) @Composable private fun OfflineCenterSummaryCard( @@ -1172,7 +1644,7 @@ private fun OfflineCenterSummaryCard( Text( when (snapshot?.support) { FileOfflineCenterSupport.Available -> - "${snapshot.items.count { it.availability == FileOfflineAvailability.Available }} available · " + + "${snapshot.items.count { it.availability == FileOfflineAvailability.Available }} available | " + "${snapshot.items.size} tracked" FileOfflineCenterSupport.InventoryUnavailable -> if ( @@ -1283,7 +1755,7 @@ private fun OfflineCenterItemCard( ) if (metadata.isNotEmpty()) { Text( - metadata.joinToString(" · "), + metadata.joinToString(" | "), style = MaterialTheme.typography.bodySmall, color = if (item.availability in setOf( FileOfflineAvailability.Failed, @@ -1367,6 +1839,12 @@ private fun FileSyncCenterActionResult.fileSyncCenterMessage(): String = when (t is FileSyncCenterActionResult.Unsupported -> reason } +private fun VirtualFileStorageActionResult.virtualFileStorageMessage(): String = when (this) { + is VirtualFileStorageActionResult.Completed -> message + is VirtualFileStorageActionResult.Rejected -> reason + is VirtualFileStorageActionResult.Unsupported -> reason +} + private fun FileSyncDirection.readableSyncDirection(): String = when (this) { FileSyncDirection.Bidirectional -> "Two-way" FileSyncDirection.DownloadOnly -> "Nextcloud to device" @@ -1379,9 +1857,9 @@ internal fun fileSyncRouteLabel( ): String { val remote = "Nextcloud /${remoteRootPath.trimStart('/')}" return when (direction) { - FileSyncDirection.Bidirectional -> "Device ↔ $remote" - FileSyncDirection.DownloadOnly -> "$remote → device" - FileSyncDirection.UploadOnly -> "Device → $remote" + FileSyncDirection.Bidirectional -> "Device <-> $remote" + FileSyncDirection.DownloadOnly -> "$remote -> device" + FileSyncDirection.UploadOnly -> "Device -> $remote" } } @@ -1473,7 +1951,6 @@ internal fun fileOfflineRefreshEnabled( private const val ADD_PAIR_BUSY_ID = "__adding_sync_pair__" private const val MAX_VISIBLE_PAIR_CONFLICTS = 5 private const val MAX_VISIBLE_MEDIA_FOLDER_SUGGESTIONS = 6 - private data class PendingFileSyncDecision( val pair: FileSyncPairSummary, val conflict: FileSyncConflictSummary, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt index c6173a802..c11031c04 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt @@ -1,11 +1,14 @@ package dev.obiente.nextcloudnative.app +import kotlinx.serialization.Serializable + /** * Platform-facing view of a durable local-folder/Nextcloud-folder synchronization pair. * * The opaque [localRootId] is a persisted SAF grant, bookmark, or equivalent platform handle. * It must not contain credentials and is never interpreted by common UI code. */ +@Serializable data class FileSyncLocalRoot( val localRootId: String, val displayName: String, @@ -27,6 +30,7 @@ enum class MediaSyncFolderDiscoverySupport { Unsupported, } +@Serializable enum class MediaSyncFolderKind { Camera, Screenshots, @@ -46,6 +50,7 @@ enum class MediaSyncFolderAccess { * [localRootHint] is an opaque platform-owned sync root. Common code may pass it back to the * platform, but must never interpret it as a path. */ +@Serializable data class MediaSyncFolderSuggestion( val localRootHint: String, val displayName: String, @@ -145,6 +150,7 @@ const val MAX_MEDIA_PREVIEW_THUMBNAIL_BYTES = 256 * 1_024 data class FileSyncPairSummary( val id: String, val localDisplayName: String, + val localRootPath: String? = null, val remoteRootPath: String, val configuration: FileSyncConfiguration, val readyCount: Int, @@ -155,16 +161,20 @@ data class FileSyncPairSummary( val completedCount: Int = 0, val lastScanEpochMillis: Long?, val scheduleDescription: String? = null, + val skippedReasons: List = emptyList(), ) { init { require(id.isSafeFileSyncCenterText(256)) require(localDisplayName.isSafeFileSyncCenterText(256)) + require(localRootPath == null || localRootPath.isSafeFileSyncCenterText(2_048)) if (remoteRootPath.isNotEmpty()) requireValidSyncPath(remoteRootPath) require(listOf(readyCount, runningCount, failedCount, skippedCount, completedCount).all { it >= 0 }) require(conflicts.size <= 20_000) require(conflicts.map(FileSyncConflictSummary::workId).distinct().size == conflicts.size) require(lastScanEpochMillis == null || lastScanEpochMillis >= 0L) require(scheduleDescription == null || scheduleDescription.isSafeFileSyncCenterText(256)) + require(skippedReasons.size <= 20) + require(skippedReasons.all { reason -> reason.isSafeFileSyncCenterText(1_024) }) } } @@ -216,11 +226,13 @@ sealed interface FileSyncCenterActionResult { fun FileSyncPair.toCenterSummary( localDisplayName: String, + localRootPath: String? = null, scheduleDescription: String? = null, ): FileSyncPairSummary = FileSyncPairSummary( id = id, localDisplayName = localDisplayName, + localRootPath = localRootPath, remoteRootPath = remoteRootPath, configuration = configuration, readyCount = workItems.count { it.state == FileSyncExecutionState.Ready }, @@ -242,6 +254,11 @@ fun FileSyncPair.toCenterSummary( completedCount = baselines.size, lastScanEpochMillis = lastScanEpochMillis, scheduleDescription = scheduleDescription, + skippedReasons = workItems.mapNotNull { work -> + (work.operation as? FileSyncOperation.Skipped) + ?.takeIf { work.state == FileSyncExecutionState.Skipped } + ?.reason + }.distinct().take(20), ) private fun String.isSafeFileSyncCenterText(maxLength: Int): Boolean = diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt index 6676c9f34..f6523776c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt @@ -62,8 +62,8 @@ data class FileSyncDecision( val state: FileSyncDecisionState = FileSyncDecisionState.Pending, ) { init { - require(choices == allowedFileSyncDecisions(reason)) { - "The decision choices do not match the conflict reason." + require(choices.isNotEmpty() && choices.all { it in allowedFileSyncDecisions(reason) }) { + "The decision choices are not valid for the conflict reason." } val resolved = state as? FileSyncDecisionState.Resolved require(resolved == null || resolved.choice in choices) @@ -217,12 +217,25 @@ fun scanFileSyncPair( require(pair.workItems.none { it.state == FileSyncExecutionState.Running }) { "A sync pair cannot be rescanned while work is running." } - val localByPath = localEntries.associateBy(LocalSyncEntry::relativePath) - val remoteByPath = remoteEntries.associateBy(RemoteSyncEntry::relativePath) - require(localByPath.size == localEntries.size) { "The local sync snapshot contains duplicate paths." } - require(remoteByPath.size == remoteEntries.size) { "The remote sync snapshot contains duplicate paths." } - val baselineByPath = pair.baselines.associateBy(FileSyncBaseline::relativePath) - val plan = planFileSync(localEntries, remoteEntries, pair.baselines, pair.configuration) + require(localEntries.map(LocalSyncEntry::relativePath).distinct().size == localEntries.size) { + "The local sync snapshot contains duplicate paths." + } + require(remoteEntries.map(RemoteSyncEntry::relativePath).distinct().size == remoteEntries.size) { + "The remote sync snapshot contains duplicate paths." + } + val scopedLocalEntries = localEntries.filter { entry -> + pair.configuration.includesSyncPath(entry.relativePath, entry.kind) + } + val scopedRemoteEntries = remoteEntries.filter { entry -> + pair.configuration.includesSyncPath(entry.relativePath, entry.kind) + } + val scopedBaselines = pair.baselines.filter { baseline -> + pair.configuration.includesSyncPath(baseline.relativePath, baseline.kind) + } + val localByPath = scopedLocalEntries.associateBy(LocalSyncEntry::relativePath) + val remoteByPath = scopedRemoteEntries.associateBy(RemoteSyncEntry::relativePath) + val baselineByPath = scopedBaselines.associateBy(FileSyncBaseline::relativePath) + val plan = planFileSync(scopedLocalEntries, scopedRemoteEntries, scopedBaselines, pair.configuration) require(plan.operations.size <= MAX_FILE_SYNC_WORK_ITEMS) { "The sync plan contains too much work." } var nextId = pair.nextWorkId val work = plan.operations.map { operation -> @@ -244,11 +257,14 @@ fun scanFileSyncPair( operation = operation, state = operation.initialExecutionState(), decision = (operation as? FileSyncOperation.NeedsDecision)?.let { needed -> - FileSyncDecision(needed.reason, allowedFileSyncDecisions(needed.reason)) + FileSyncDecision( + needed.reason, + allowedFileSyncDecisions(needed.reason, pair.configuration), + ) }, ) } - val structuralBaselines = localEntries + val structuralBaselines = scopedLocalEntries .asSequence() .filter { it.kind == SyncEntryKind.Directory && it.relativePath !in baselineByPath } .mapNotNull { local -> @@ -264,7 +280,7 @@ fun scanFileSyncPair( } } .toList() - val contentVerifiedBaselines = localEntries + val contentVerifiedBaselines = scopedLocalEntries .asSequence() .filter { local -> local.kind == SyncEntryKind.File && @@ -289,27 +305,39 @@ fun scanFileSyncPair( structuralBaselines + contentVerifiedBaselines ).sortedBy(FileSyncBaseline::relativePath), - workItems = work.sortedWith(fileSyncExecutionComparator()), + workItems = work.sortedWith(fileSyncExecutionComparator(pair.configuration)), nextWorkId = nextId, lastScanEpochMillis = nowEpochMillis, ) } -private fun fileSyncExecutionComparator(): Comparator = Comparator { left, right -> +private fun fileSyncExecutionComparator( + configuration: FileSyncConfiguration, +): Comparator = Comparator { left, right -> val leftDelete = left.operation is FileSyncOperation.DeleteLocal || left.operation is FileSyncOperation.DeleteRemote val rightDelete = right.operation is FileSyncOperation.DeleteLocal || right.operation is FileSyncOperation.DeleteRemote + val leftDirectory = left.observedLocal?.kind == SyncEntryKind.Directory || + left.observedRemote?.kind == SyncEntryKind.Directory + val rightDirectory = right.observedLocal?.kind == SyncEntryKind.Directory || + right.observedRemote?.kind == SyncEntryKind.Directory when { leftDelete != rightDelete -> if (leftDelete) 1 else -1 + leftDelete && leftDirectory != rightDirectory -> if (leftDirectory) 1 else -1 leftDelete -> compareValues( right.relativePath.count { it == '/' }, left.relativePath.count { it == '/' }, ).takeIf { it != 0 } ?: compareValues(left.relativePath, right.relativePath) - else -> compareValues( + leftDirectory != rightDirectory -> if (leftDirectory) -1 else 1 + leftDirectory -> compareValues( left.relativePath.count { it == '/' }, right.relativePath.count { it == '/' }, ).takeIf { it != 0 } ?: compareValues(left.relativePath, right.relativePath) + else -> compareValues( + configuration.fileSyncPriority(left.relativePath), + configuration.fileSyncPriority(right.relativePath), + ).takeIf { it != 0 } ?: compareValues(left.relativePath, right.relativePath) } } @@ -430,6 +458,27 @@ fun retryFileSyncOperation( } } +/** Explicit user recovery for work that exhausted the automatic retry budget. */ +fun resetExhaustedFileSyncOperations( + state: FileSyncCoordinatorState, + pairId: String, +): FileSyncCoordinatorState = state.updatePair(pairId) { pair -> + pair.copy( + workItems = pair.workItems.map { work -> + if (work.state == FileSyncExecutionState.Failed && work.attemptCount >= MAX_FILE_SYNC_ATTEMPTS) { + work.copy( + state = FileSyncExecutionState.Ready, + attemptCount = 0, + lastAttemptEpochMillis = null, + failureMessage = null, + ) + } else { + work + } + }, + ) +} + internal fun recoverInterruptedFileSyncWork(state: FileSyncCoordinatorState): FileSyncCoordinatorState = state.copy( pairs = state.pairs.map { pair -> @@ -468,6 +517,25 @@ private fun allowedFileSyncDecisions(reason: FileSyncDecisionReason): Set = allowedFileSyncDecisions(reason).filterTo(linkedSetOf()) { choice -> + when (choice) { + FileSyncDecisionChoice.PropagateDeletion -> when (reason) { + FileSyncDecisionReason.LocalDeletion -> configuration.direction != FileSyncDirection.DownloadOnly + FileSyncDecisionReason.RemoteDeletion -> configuration.direction != FileSyncDirection.UploadOnly + else -> true + } + FileSyncDecisionChoice.RestoreMissing -> when (reason) { + FileSyncDecisionReason.LocalDeletion -> configuration.direction != FileSyncDirection.UploadOnly + FileSyncDecisionReason.RemoteDeletion -> configuration.direction != FileSyncDirection.DownloadOnly + else -> true + } + else -> true + } +} + private fun resolveDecisionOperation( pair: FileSyncPair, work: FileSyncWorkItem, @@ -492,10 +560,20 @@ private fun resolveDecisionOperation( ) } FileSyncDecisionChoice.PropagateDeletion -> when (work.decision?.reason) { - FileSyncDecisionReason.LocalDeletion -> + FileSyncDecisionReason.LocalDeletion -> if ( + pair.configuration.hasPartialDirectoryView() && work.observedRemote?.kind == SyncEntryKind.Directory + ) { + FileSyncOperation.Skipped(work.relativePath, PARTIAL_DIRECTORY_DECISION_REASON) + } else { FileSyncOperation.DeleteRemote(work.relativePath, requireNotNull(work.observedRemote).etag) - FileSyncDecisionReason.RemoteDeletion -> + } + FileSyncDecisionReason.RemoteDeletion -> if ( + pair.configuration.hasPartialDirectoryView() && work.observedLocal?.kind == SyncEntryKind.Directory + ) { + FileSyncOperation.Skipped(work.relativePath, PARTIAL_DIRECTORY_DECISION_REASON) + } else { FileSyncOperation.DeleteLocal(work.relativePath, requireNotNull(work.observedLocal).revision) + } else -> error("There is no deletion to propagate.") } FileSyncDecisionChoice.RestoreMissing -> when (work.decision?.reason) { @@ -509,6 +587,12 @@ private fun resolveDecisionOperation( FileSyncOperation.Skipped(work.relativePath, "Skipped by the user for this observed generation.") } +private fun FileSyncConfiguration.hasPartialDirectoryView(): Boolean = + selectedPaths.isNotEmpty() || ignoredPatterns.isNotEmpty() + +private const val PARTIAL_DIRECTORY_DECISION_REASON = + "Directory deletion is paused because selective or ignored items may exist below it." + private fun FileSyncWorkItem.sameGeneration( planned: FileSyncOperation, local: LocalSyncEntry?, @@ -571,6 +655,9 @@ private fun requireValidFileSyncPair(pair: FileSyncPair) { if (pair.remoteRootPath.isNotEmpty()) requireValidSyncPath(pair.remoteRootPath) require(pair.remoteRootPath.length <= MAX_FILE_SYNC_PATH_LENGTH) require(pair.configuration.deviceLabel.isSafeSyncText(MAX_FILE_SYNC_DEVICE_LABEL_LENGTH)) + pair.configuration.selectedPaths.forEach { + require(it.length <= MAX_FILE_SYNC_PATH_LENGTH) + } require(pair.baselines.size <= MAX_FILE_SYNC_ENTRIES) { "The sync pair contains too many baselines." } require(pair.workItems.size <= MAX_FILE_SYNC_WORK_ITEMS) { "The sync pair contains too much work." } requireUniqueCoordinatorPaths(pair.baselines.map(FileSyncBaseline::relativePath), "baseline") @@ -582,6 +669,11 @@ private fun requireValidFileSyncPair(pair: FileSyncPair) { pair.workItems.forEach { work -> requireBoundedWorkItem(work) val resolved = work.decision?.state as? FileSyncDecisionState.Resolved + work.decision?.let { decision -> + require(decision.choices == allowedFileSyncDecisions(decision.reason, pair.configuration)) { + "The persisted sync decision choices do not match the pair direction." + } + } val expectedOperation = if (resolved != null) { resolveDecisionOperation(pair, work, resolved.choice) } else { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshot.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshot.kt index d80935401..019c28ce4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshot.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshot.kt @@ -56,6 +56,9 @@ private data class FileSyncPairSnapshotV1( val deviceLabel: String, val networkPolicy: String = FileSyncNetworkPolicy.AnyConnection.name, val powerPolicy: String = FileSyncPowerPolicy.BatteryNotLow.name, + val selectedPaths: List = emptyList(), + val ignoredPatterns: List = emptyList(), + val priorityPatterns: List = emptyList(), val baselines: List, val workItems: List, val nextWorkId: Long, @@ -132,6 +135,9 @@ private fun FileSyncPair.toSnapshot(): FileSyncPairSnapshotV1 = FileSyncPairSnap deviceLabel = configuration.deviceLabel, networkPolicy = configuration.networkPolicy.name, powerPolicy = configuration.powerPolicy.name, + selectedPaths = configuration.selectedPaths, + ignoredPatterns = configuration.ignoredPatterns, + priorityPatterns = configuration.priorityRules.map(FileSyncPriorityRule::pattern), baselines = baselines.sortedBy(FileSyncBaseline::relativePath).map(FileSyncBaseline::toSnapshot), workItems = workItems.sortedBy(FileSyncWorkItem::id).map(FileSyncWorkItem::toSnapshot), nextWorkId = nextWorkId, @@ -150,6 +156,9 @@ private fun FileSyncPairSnapshotV1.toDomain(): FileSyncPair = FileSyncPair( deviceLabel = deviceLabel, networkPolicy = enumValueOf(networkPolicy), powerPolicy = enumValueOf(powerPolicy), + selectedPaths = selectedPaths, + ignoredPatterns = ignoredPatterns, + priorityRules = priorityPatterns.map(::FileSyncPriorityRule), ), baselines = baselines.map(FileSyncBaselineSnapshotV1::toDomain), workItems = workItems.map(FileSyncWorkSnapshotV1::toDomain), diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt new file mode 100644 index 000000000..a7fa77ca9 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt @@ -0,0 +1,1720 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudRadii +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing +import dev.obiente.nextcloudnative.app.design.NextcloudTheme + +private enum class FileSyncListFilter { + All, + Active, + Attention, + Ready, +} + +internal enum class FileSyncSetupStep(val title: String) { + Locations("Locations"), + Direction("Direction"), + Rules("What syncs"), + Review("Review"), +} + +private enum class FileSyncRulePreset(val title: String, val supportingText: String) { + Everything("Everything", "Sync the whole folder without priority groups."), + PhotoRawFirst("Photos and RAW first", "Ignore temporary previews and transfer RAW before JPEG."), + ChooseFolders("Choose folders", "Sync only the folders and files you select."), +} + +@Composable +internal fun FileSyncWorkspace( + snapshot: FileSyncCenterSnapshot?, + loading: Boolean, + busyPairId: String?, + onAdd: () -> Unit, + onRun: (FileSyncPairSummary) -> Unit, + onRemove: (FileSyncPairSummary) -> Unit, + onResolve: (FileSyncPairSummary, FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, + initialSelectedPairId: String? = null, +) { + val pairs = snapshot?.pairs.orEmpty() + var selectedPairId by rememberSaveable(initialSelectedPairId) { mutableStateOf(initialSelectedPairId) } + var filter by rememberSaveable { mutableStateOf(FileSyncListFilter.All) } + LaunchedEffect(pairs.map(FileSyncPairSummary::id)) { + if (selectedPairId !in pairs.map(FileSyncPairSummary::id)) { + selectedPairId = pairs.firstOrNull()?.id + } + } + val visiblePairs = remember(pairs, filter) { + pairs.filter { pair -> + when (filter) { + FileSyncListFilter.All -> true + FileSyncListFilter.Active -> pair.runningCount > 0 + FileSyncListFilter.Attention -> pair.failedCount > 0 || pair.conflicts.isNotEmpty() + FileSyncListFilter.Ready -> pair.readyCount > 0 && pair.runningCount == 0 + } + } + } + val selectedPair = pairs.firstOrNull { it.id == selectedPairId } + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val desktop = maxWidth >= 940.dp + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + FileSyncWorkspaceHeader( + pairs = pairs, + loading = loading, + actionsEnabled = busyPairId == null, + onAdd = onAdd, + ) + snapshot?.limitation?.let { limitation -> + FileSyncNotice(limitation) + } + if (loading && snapshot == null) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + if (!loading && pairs.isEmpty()) { + FileSyncEmptyState(onAdd = onAdd) + } else if (pairs.isNotEmpty()) { + FileSyncFilters( + selected = filter, + pairs = pairs, + onSelected = { filter = it }, + ) + if (desktop) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.Top, + ) { + FileSyncMapTable( + pairs = visiblePairs, + selectedPairId = selectedPairId, + busyPairId = busyPairId, + actionsEnabled = busyPairId == null, + onSelect = { selectedPairId = it.id }, + onRun = onRun, + modifier = Modifier.weight(1.65f), + ) + FileSyncPairInspector( + pair = selectedPair, + busy = selectedPair?.id == busyPairId, + actionsEnabled = busyPairId == null, + onRun = { selectedPair?.let(onRun) }, + onRemove = { selectedPair?.let(onRemove) }, + onResolve = onResolve, + modifier = Modifier.weight(1f), + ) + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + visiblePairs.forEach { pair -> + FileSyncMobilePairCard( + pair = pair, + expanded = pair.id == selectedPairId, + busy = pair.id == busyPairId, + actionsEnabled = busyPairId == null, + onSelect = { + selectedPairId = if (selectedPairId == pair.id) null else pair.id + }, + onRun = { onRun(pair) }, + onRemove = { onRemove(pair) }, + onResolve = { conflict, choice -> onResolve(pair, conflict, choice) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun FileSyncWorkspaceHeader( + pairs: List, + loading: Boolean, + actionsEnabled: Boolean, + onAdd: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Folder sync", style = MaterialTheme.typography.headlineSmall) + Text( + when { + loading -> "Checking sync health..." + pairs.any { it.failedCount > 0 || it.conflicts.isNotEmpty() } -> + "${pairs.count { it.failedCount > 0 || it.conflicts.isNotEmpty() }} syncs need attention" + pairs.any { it.runningCount > 0 } -> "Syncing changes safely" + pairs.isNotEmpty() -> "All folder mappings are ready" + else -> "Keep chosen folders in sync with Nextcloud" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button(enabled = actionsEnabled, onClick = onAdd) { + Icon(NextcloudIcons.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(NextcloudSpacing.Small)) + Text("Add sync") + } + } +} + +@Composable +private fun FileSyncFilters( + selected: FileSyncListFilter, + pairs: List, + onSelected: (FileSyncListFilter) -> Unit, +) { + val counts = mapOf( + FileSyncListFilter.All to pairs.size, + FileSyncListFilter.Active to pairs.count { it.runningCount > 0 }, + FileSyncListFilter.Attention to pairs.count { it.failedCount > 0 || it.conflicts.isNotEmpty() }, + FileSyncListFilter.Ready to pairs.count { it.readyCount > 0 && it.runningCount == 0 }, + ) + @Composable + fun filterChip(option: FileSyncListFilter, modifier: Modifier = Modifier, fill: Boolean = false) { + FilterChip( + selected = selected == option, + onClick = { onSelected(option) }, + label = { Text("${option.name} ${counts.getValue(option)}") }, + modifier = if (fill) modifier.fillMaxWidth() else modifier, + ) + } + BoxWithConstraints(Modifier.fillMaxWidth()) { + if (maxWidth < 520.dp) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + FileSyncListFilter.entries.chunked(2).forEach { filters -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + filters.forEach { option -> filterChip(option, Modifier.weight(1f), fill = true) } + } + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + FileSyncListFilter.entries.forEach { option -> filterChip(option) } + } + } + } +} + +@Composable +private fun FileSyncMapTable( + pairs: List, + selectedPairId: String?, + busyPairId: String?, + actionsEnabled: Boolean, + onSelect: (FileSyncPairSummary) -> Unit, + onRun: (FileSyncPairSummary) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + FileSyncTableHeader("Sync pair", Modifier.weight(1.35f)) + FileSyncTableHeader("Mapping", Modifier.weight(1.65f)) + FileSyncTableHeader("Status", Modifier.weight(1f)) + FileSyncTableHeader("Queued", Modifier.width(72.dp)) + Spacer(Modifier.width(96.dp)) + } + HorizontalDivider() + if (pairs.isEmpty()) { + Text( + "No syncs match this filter.", + modifier = Modifier.padding(NextcloudSpacing.Large), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + pairs.forEachIndexed { index, pair -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onSelect(pair) }, + color = if (selectedPairId == pair.id) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.38f) + } else { + NextcloudTheme.colors.appTile + }, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1.35f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + NextcloudIcons.Folder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + pair.localDisplayName, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + pair.configuration.direction.syncDirectionTitle(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Column(modifier = Modifier.weight(1.65f)) { + Text( + pair.localRootPath ?: "This device", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${pair.configuration.direction.syncDirectionGlyph()} /${pair.remoteRootPath}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + FileSyncHealthBadge(pair) + } + Text( + pair.queuedLabel(), + modifier = Modifier.width(72.dp), + style = MaterialTheme.typography.bodySmall, + ) + if (pair.id == busyPairId) { + Box(Modifier.width(96.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } + } else { + TextButton( + enabled = actionsEnabled, + onClick = { onRun(pair) }, + modifier = Modifier.width(96.dp), + ) { Text("Sync now") } + } + } + } + if (index != pairs.lastIndex) HorizontalDivider() + } + } + } +} + +@Composable +private fun FileSyncTableHeader(label: String, modifier: Modifier) { + Text( + label, + modifier = modifier, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun FileSyncMobilePairCard( + pair: FileSyncPairSummary, + expanded: Boolean, + busy: Boolean, + actionsEnabled: Boolean, + onSelect: () -> Unit, + onRun: () -> Unit, + onRemove: () -> Unit, + onResolve: (FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke( + 1.dp, + if (expanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onSelect) + .padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(NextcloudIcons.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f)) { + Text(pair.localDisplayName, fontWeight = FontWeight.SemiBold) + Text( + "This device ${pair.configuration.direction.syncDirectionGlyph()} /${pair.remoteRootPath}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + FileSyncHealthBadge(pair) + Icon( + NextcloudIcons.ExpandMore, + contentDescription = if (expanded) "Collapse sync details" else "Expand sync details", + ) + } + if (pair.runningCount > 0) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp)) + } + if (expanded) { + HorizontalDivider() + FileSyncPairDetails( + pair = pair, + busy = busy, + actionsEnabled = actionsEnabled, + onRun = onRun, + onRemove = onRemove, + onResolve = onResolve, + compact = true, + ) + } + } + } +} + +@Composable +private fun FileSyncPairInspector( + pair: FileSyncPairSummary?, + busy: Boolean, + actionsEnabled: Boolean, + onRun: () -> Unit, + onRemove: () -> Unit, + onResolve: (FileSyncPairSummary, FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + if (pair == null) { + Text( + "Select a sync to see its mapping, rules, and recovery actions.", + modifier = Modifier.padding(NextcloudSpacing.Large), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(pair.localDisplayName, style = MaterialTheme.typography.titleLarge) + Text( + "Sync details", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FileSyncHealthBadge(pair) + } + HorizontalDivider() + FileSyncPairDetails( + pair = pair, + busy = busy, + actionsEnabled = actionsEnabled, + onRun = onRun, + onRemove = onRemove, + onResolve = { conflict, choice -> onResolve(pair, conflict, choice) }, + compact = false, + ) + } + } + } +} + +@Composable +private fun FileSyncPairDetails( + pair: FileSyncPairSummary, + busy: Boolean, + actionsEnabled: Boolean, + onRun: () -> Unit, + onRemove: () -> Unit, + onResolve: (FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, + compact: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(if (compact) NextcloudSpacing.Medium else NextcloudSpacing.Small), + ) { + if (compact) { + FileSyncConflictBlock(pair, actionsEnabled, onResolve) + FileSyncPrimaryActions( + compact = true, + busy = busy, + actionsEnabled = actionsEnabled, + onRun = onRun, + onRemove = onRemove, + ) + } else { + FileSyncPrimaryActions( + compact = false, + busy = busy, + actionsEnabled = actionsEnabled, + onRun = onRun, + onRemove = onRemove, + ) + FileSyncConflictBlock(pair, actionsEnabled, onResolve) + } + FileSyncDetailBlock("Mapping") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + FileSyncLocationCard( + title = "This device", + path = pair.localRootPath ?: pair.localDisplayName, + icon = NextcloudIcons.FolderOpen, + modifier = Modifier.weight(1f), + ) + Text(pair.configuration.direction.syncDirectionGlyph(), fontWeight = FontWeight.Bold) + FileSyncLocationCard( + title = "Nextcloud", + path = "/${pair.remoteRootPath}", + icon = NextcloudIcons.Cloud, + modifier = Modifier.weight(1f), + ) + } + Text( + pair.configuration.direction.syncDirectionDescription(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + FileSyncDetailBlock("Health") { + FileSyncHealthLine("Queued", pair.queuedLabel(), problem = false) + FileSyncHealthLine("Completed", pair.completedCount.toString(), problem = false) + FileSyncHealthLine( + "Needs attention", + "${pair.conflicts.size} ${if (pair.conflicts.size == 1) "conflict" else "conflicts"}, " + + "${pair.failedCount} failed", + problem = pair.conflicts.isNotEmpty() || pair.failedCount > 0, + ) + if (pair.skippedCount > 0) { + FileSyncHealthLine( + "Paused", + "${pair.skippedCount} ${if (pair.skippedCount == 1) "item" else "items"}", + problem = true, + ) + pair.skippedReasons.forEach { reason -> + Text( + reason, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + pair.scheduleDescription?.let { FileSyncHealthLine("Schedule", it, problem = false) } + } + FileSyncDetailBlock("Rules") { + Text(pair.selectionSummary(), style = MaterialTheme.typography.bodySmall) + Text(pair.ignoreSummary(), style = MaterialTheme.typography.bodySmall) + Text(pair.prioritySummary(), style = MaterialTheme.typography.bodySmall) + Text( + "${pair.configuration.networkPolicy.syncNetworkTitle()} - " + + pair.configuration.powerPolicy.syncPowerTitle(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun FileSyncConflictBlock( + pair: FileSyncPairSummary, + actionsEnabled: Boolean, + onResolve: (FileSyncConflictSummary, FileSyncDecisionChoice) -> Unit, +) { + pair.conflicts.firstOrNull()?.let { conflict -> + FileSyncDetailBlock("Conflict: ${conflict.relativePath}", attention = true) { + Text( + conflict.reason.syncDecisionReasonTitle(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + conflict.choices.sortedBy(FileSyncDecisionChoice::ordinal).chunked(2).forEach { choices -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + choices.forEach { choice -> + OutlinedButton( + enabled = actionsEnabled, + onClick = { onResolve(conflict, choice) }, + modifier = Modifier.weight(1f), + ) { Text(choice.syncDecisionTitle(), maxLines = 1) } + } + if (choices.size == 1) Spacer(Modifier.weight(1f)) + } + } + } + } + } +} + +@Composable +private fun FileSyncPrimaryActions( + compact: Boolean, + busy: Boolean, + actionsEnabled: Boolean, + onRun: () -> Unit, + onRemove: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedButton( + enabled = actionsEnabled, + onClick = onRemove, + modifier = Modifier.weight(1f), + ) { Text(if (compact) "Remove" else "Remove sync") } + Button( + enabled = actionsEnabled, + onClick = onRun, + modifier = Modifier.weight(1f), + ) { + if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("Sync now") + } + } +} + +@Composable +private fun FileSyncLocationCard( + title: String, + path: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Small), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(title, style = MaterialTheme.typography.labelSmall) + } + Text(path, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun FileSyncDetailBlock( + title: String, + attention: Boolean = false, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text( + title, + style = MaterialTheme.typography.labelLarge, + color = if (attention) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + content() + } +} + +@Composable +private fun FileSyncHealthLine(label: String, value: String, problem: Boolean) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + style = MaterialTheme.typography.bodySmall, + color = if (problem) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + fontWeight = if (problem) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} + +@Composable +private fun FileSyncHealthBadge(pair: FileSyncPairSummary, modifier: Modifier = Modifier) { + val attention = pair.failedCount > 0 || pair.conflicts.isNotEmpty() + val paused = pair.skippedCount > 0 + val label = when { + attention -> "Attention" + pair.runningCount > 0 -> "Syncing" + pair.readyCount > 0 -> "Ready" + paused -> "Paused" + else -> "Up to date" + } + Surface( + modifier = modifier, + color = when { + attention -> MaterialTheme.colorScheme.errorContainer + pair.runningCount > 0 -> MaterialTheme.colorScheme.primaryContainer + paused -> MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.secondaryContainer + }, + shape = RoundedCornerShape(999.dp), + ) { + Text( + label, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + style = MaterialTheme.typography.labelSmall, + color = when { + attention -> MaterialTheme.colorScheme.onErrorContainer + pair.runningCount > 0 -> MaterialTheme.colorScheme.onPrimaryContainer + paused -> MaterialTheme.colorScheme.onTertiaryContainer + else -> MaterialTheme.colorScheme.onSecondaryContainer + }, + maxLines = 1, + ) + } +} + +@Composable +private fun FileSyncNotice(message: String) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Row( + modifier = Modifier.padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(NextcloudIcons.Info, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(message, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun FileSyncEmptyState(onAdd: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon(NextcloudIcons.FolderOpen, contentDescription = null, modifier = Modifier.size(40.dp)) + Text("Keep a folder available everywhere", style = MaterialTheme.typography.titleMedium) + Text( + "Choose a folder on this device and where it belongs in Nextcloud. Nothing is deleted while setup is incomplete.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onAdd) { Text("Add your first sync") } + } + } +} + +@Composable +internal fun GuidedAddFolderSyncDialog( + localRoot: FileSyncLocalRoot, + mediaSuggestion: MediaSyncFolderSuggestion?, + remotePath: String, + configuration: FileSyncConfiguration, + mediaPreview: MediaSyncFolderPreview?, + mediaPreviewLoading: Boolean, + mediaPreviewError: String?, + busy: Boolean, + onDismiss: () -> Unit, + onChooseDestination: () -> Unit, + onChooseSelectedPaths: () -> Unit = {}, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + onAdd: () -> Unit, +) { + Dialog( + onDismissRequest = { if (!busy) onDismiss() }, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.Medium)) { + val compact = maxWidth < 720.dp + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + FileSyncSetupSurface( + localRoot = localRoot, + mediaSuggestion = mediaSuggestion, + remotePath = remotePath, + configuration = configuration, + mediaPreview = mediaPreview, + mediaPreviewLoading = mediaPreviewLoading, + mediaPreviewError = mediaPreviewError, + busy = busy, + onDismiss = onDismiss, + onChooseDestination = onChooseDestination, + onChooseSelectedPaths = onChooseSelectedPaths, + onConfigurationChanged = onConfigurationChanged, + onAdd = onAdd, + modifier = if (compact) { + Modifier.fillMaxSize() + } else { + Modifier.fillMaxWidth().widthIn(max = 920.dp).heightIn(min = 620.dp, max = 760.dp) + }, + ) + } + } + } +} + +@Composable +internal fun FileSyncSetupSurface( + localRoot: FileSyncLocalRoot, + mediaSuggestion: MediaSyncFolderSuggestion?, + remotePath: String, + configuration: FileSyncConfiguration, + mediaPreview: MediaSyncFolderPreview?, + mediaPreviewLoading: Boolean, + mediaPreviewError: String?, + busy: Boolean, + onDismiss: () -> Unit, + onChooseDestination: () -> Unit, + onChooseSelectedPaths: () -> Unit = {}, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + onAdd: () -> Unit, + modifier: Modifier = Modifier, + initialStep: FileSyncSetupStep = FileSyncSetupStep.Locations, + syntheticScopeSummary: String? = null, +) { + var stepName by rememberSaveable(localRoot.localRootId, initialStep.name) { + mutableStateOf(initialStep.name) + } + val step = FileSyncSetupStep.entries.firstOrNull { it.name == stepName } ?: initialStep + val setupStateHolder = rememberSaveableStateHolder() + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(NextcloudRadii.Large), + tonalElevation = 4.dp, + shadowElevation = 12.dp, + ) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val desktop = maxWidth >= 720.dp + Column(Modifier.fillMaxSize()) { + FileSyncSetupHeader(step = step, onDismiss = onDismiss, enabled = !busy) + HorizontalDivider() + if (desktop) { + Row(Modifier.weight(1f).fillMaxWidth()) { + FileSyncStepRail( + current = step, + onSelect = { stepName = it.name }, + modifier = Modifier.width(210.dp).fillMaxHeight(), + ) + HorizontalDivider(Modifier.width(1.dp).fillMaxHeight()) + setupStateHolder.SaveableStateProvider(step.name) { + FileSyncStepContent( + step = step, + localRoot = localRoot, + mediaSuggestion = mediaSuggestion, + remotePath = remotePath, + configuration = configuration, + mediaPreview = mediaPreview, + mediaPreviewLoading = mediaPreviewLoading, + mediaPreviewError = mediaPreviewError, + onChooseDestination = onChooseDestination, + onChooseSelectedPaths = onChooseSelectedPaths, + onConfigurationChanged = onConfigurationChanged, + syntheticScopeSummary = syntheticScopeSummary, + modifier = Modifier.weight(1f), + ) + } + } + } else { + FileSyncStepProgress(current = step, onSelect = { stepName = it.name }) + HorizontalDivider() + setupStateHolder.SaveableStateProvider(step.name) { + FileSyncStepContent( + step = step, + localRoot = localRoot, + mediaSuggestion = mediaSuggestion, + remotePath = remotePath, + configuration = configuration, + mediaPreview = mediaPreview, + mediaPreviewLoading = mediaPreviewLoading, + mediaPreviewError = mediaPreviewError, + onChooseDestination = onChooseDestination, + onChooseSelectedPaths = onChooseSelectedPaths, + onConfigurationChanged = onConfigurationChanged, + syntheticScopeSummary = syntheticScopeSummary, + modifier = Modifier.weight(1f), + ) + } + } + HorizontalDivider() + FileSyncSetupFooter( + step = step, + busy = busy, + configuration = configuration, + mediaReady = isMediaFolderPreviewReady(mediaSuggestion, mediaPreview), + onBack = { stepName = FileSyncSetupStep.entries[step.ordinal - 1].name }, + onNext = { stepName = FileSyncSetupStep.entries[step.ordinal + 1].name }, + onAdd = onAdd, + ) + } + } + } +} + +@Composable +private fun FileSyncSetupHeader(step: FileSyncSetupStep, onDismiss: () -> Unit, enabled: Boolean) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Add sync", style = MaterialTheme.typography.headlineSmall) + Text( + "Step ${step.ordinal + 1} of ${FileSyncSetupStep.entries.size}: ${step.title}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(enabled = enabled, onClick = onDismiss) { Text("Close") } + } +} + +@Composable +private fun FileSyncStepRail( + current: FileSyncSetupStep, + onSelect: (FileSyncSetupStep) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + FileSyncSetupStep.entries.forEach { step -> + Surface( + modifier = Modifier.fillMaxWidth().clickable { onSelect(step) }, + color = if (step == current) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Row( + modifier = Modifier.padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + FileSyncStepNumber(step = step, selected = step == current) + Text(step.title, fontWeight = if (step == current) FontWeight.SemiBold else FontWeight.Normal) + } + } + } + Spacer(Modifier.height(NextcloudSpacing.Medium)) + Text( + "Review each option before adding the sync. Setup never deletes existing files.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun FileSyncStepProgress(current: FileSyncSetupStep, onSelect: (FileSyncSetupStep) -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = NextcloudSpacing.Medium, vertical = NextcloudSpacing.Small), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FileSyncSetupStep.entries.forEach { step -> + Column( + modifier = Modifier.clickable { onSelect(step) }.padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + FileSyncStepNumber(step, selected = step == current) + Text(step.title, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + } + } +} + +@Composable +private fun FileSyncStepNumber(step: FileSyncSetupStep, selected: Boolean) { + Surface( + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(999.dp), + ) { + Box(Modifier.size(30.dp), contentAlignment = Alignment.Center) { + Text( + (step.ordinal + 1).toString(), + style = MaterialTheme.typography.labelMedium, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun FileSyncStepContent( + step: FileSyncSetupStep, + localRoot: FileSyncLocalRoot, + mediaSuggestion: MediaSyncFolderSuggestion?, + remotePath: String, + configuration: FileSyncConfiguration, + mediaPreview: MediaSyncFolderPreview?, + mediaPreviewLoading: Boolean, + mediaPreviewError: String?, + onChooseDestination: () -> Unit, + onChooseSelectedPaths: () -> Unit, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + syntheticScopeSummary: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()) + .padding(NextcloudSpacing.Large), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + when (step) { + FileSyncSetupStep.Locations -> FileSyncLocationsStep( + localRoot = localRoot, + mediaSuggestion = mediaSuggestion, + remotePath = remotePath, + mediaPreview = mediaPreview, + mediaPreviewLoading = mediaPreviewLoading, + mediaPreviewError = mediaPreviewError, + onChooseDestination = onChooseDestination, + ) + FileSyncSetupStep.Direction -> FileSyncDirectionStep( + mediaSuggestion = mediaSuggestion, + configuration = configuration, + onConfigurationChanged = onConfigurationChanged, + ) + FileSyncSetupStep.Rules -> FileSyncRulesStep( + configuration = configuration, + onConfigurationChanged = onConfigurationChanged, + onChooseSelectedPaths = onChooseSelectedPaths, + syntheticScopeSummary = syntheticScopeSummary, + ) + FileSyncSetupStep.Review -> FileSyncReviewStep( + localRoot = localRoot, + remotePath = remotePath, + configuration = configuration, + onConfigurationChanged = onConfigurationChanged, + syntheticScopeSummary = syntheticScopeSummary, + ) + } + } +} + +@Composable +private fun FileSyncLocationsStep( + localRoot: FileSyncLocalRoot, + mediaSuggestion: MediaSyncFolderSuggestion?, + remotePath: String, + mediaPreview: MediaSyncFolderPreview?, + mediaPreviewLoading: Boolean, + mediaPreviewError: String?, + onChooseDestination: () -> Unit, +) { + FileSyncStepIntro("Where should changes go?", "Connect one folder on this device to one folder in Nextcloud.") + FileSyncSetupLocationRow( + icon = NextcloudIcons.FolderOpen, + eyebrow = "Folder on this device", + title = mediaSuggestion?.relativePath ?: localRoot.displayName, + supporting = "Selected and kept under your control", + ) + FileSyncSetupLocationRow( + icon = NextcloudIcons.Cloud, + eyebrow = "Folder in Nextcloud", + title = if (remotePath.isBlank()) "Files root" else "/$remotePath", + supporting = "This can have a different folder name", + actionLabel = "Choose", + onAction = onChooseDestination, + ) + if (mediaSuggestion != null) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Column(Modifier.padding(NextcloudSpacing.Medium)) { + Text("Media preview", style = MaterialTheme.typography.labelLarge) + Text( + when { + mediaPreviewLoading -> "Checking the selected media folder..." + mediaPreviewError != null -> mediaPreviewError + mediaPreview != null -> "${mediaPreview.totalItems} items - ${mediaPreview.totalBytes.fileSyncBytes()}" + else -> "Preview will appear before sync is enabled." + }, + style = MaterialTheme.typography.bodySmall, + color = if (mediaPreviewError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + FileSyncNotice("The first scan compares both locations before any transfer or deletion decision is made.") +} + +@Composable +private fun FileSyncDirectionStep( + mediaSuggestion: MediaSyncFolderSuggestion?, + configuration: FileSyncConfiguration, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, +) { + FileSyncStepIntro("How should changes move?", "Choose the behavior that matches this folder.") + val options = if (mediaSuggestion == null) FileSyncDirection.entries else listOf(FileSyncDirection.UploadOnly) + options.forEach { direction -> + FileSyncChoiceCard( + selected = configuration.direction == direction, + title = direction.syncDirectionTitle(), + supporting = direction.syncDirectionDescription(), + icon = when (direction) { + FileSyncDirection.Bidirectional -> NextcloudIcons.Refresh + FileSyncDirection.UploadOnly -> NextcloudIcons.Cloud + FileSyncDirection.DownloadOnly -> NextcloudIcons.FolderOpen + }, + onClick = { onConfigurationChanged(configuration.copy(direction = direction)) }, + ) + } + if (mediaSuggestion != null) { + FileSyncNotice("Detected media folders upload only. Nextcloud never writes back into the device's photo library.") + } +} + +@Composable +private fun FileSyncRulesStep( + configuration: FileSyncConfiguration, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + onChooseSelectedPaths: () -> Unit, + syntheticScopeSummary: String?, +) { + var customEditorVisible by rememberSaveable { + mutableStateOf(configuration.selectedPaths.isNotEmpty()) + } + FileSyncStepIntro("Choose what syncs first", "Start with a safe preset, then refine it only if you need to.") + FileSyncRulePreset.entries.forEach { preset -> + val selected = preset.matches(configuration) + FileSyncChoiceCard( + selected = selected, + title = preset.title, + supporting = preset.supportingText, + icon = when (preset) { + FileSyncRulePreset.Everything -> NextcloudIcons.Folder + FileSyncRulePreset.PhotoRawFirst -> NextcloudIcons.Photo + FileSyncRulePreset.ChooseFolders -> NextcloudIcons.CheckCircle + }, + onClick = { + val next = preset.applyTo(configuration) + customEditorVisible = preset == FileSyncRulePreset.ChooseFolders + onConfigurationChanged(next) + }, + ) + } + FileSyncScopeSummary(configuration, syntheticScopeSummary) + TextButton(onClick = { customEditorVisible = !customEditorVisible }) { + Icon(NextcloudIcons.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(NextcloudSpacing.Small)) + Text(if (customEditorVisible) "Hide custom rules" else "Customize rules") + } + if (customEditorVisible) { + StructuredFileSyncRulesEditor(configuration, onConfigurationChanged, onChooseSelectedPaths) + } +} + +@Composable +private fun FileSyncReviewStep( + localRoot: FileSyncLocalRoot, + remotePath: String, + configuration: FileSyncConfiguration, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + syntheticScopeSummary: String?, +) { + var advancedVisible by rememberSaveable { mutableStateOf(false) } + FileSyncStepIntro("Review and start safely", "The first scan creates a plan. Conflicts and deletions still require your chosen policy.") + FileSyncReviewRow("This device", localRoot.displayName) + FileSyncReviewRow("Nextcloud", if (remotePath.isBlank()) "Files root" else "/$remotePath") + FileSyncReviewRow("Direction", configuration.direction.syncDirectionTitle()) + FileSyncScopeSummary(configuration, syntheticScopeSummary) + TextButton(onClick = { advancedVisible = !advancedVisible }) { + Icon(NextcloudIcons.Settings, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(NextcloudSpacing.Small)) + Text(if (advancedVisible) "Hide safety settings" else "Safety, network, and power") + } + if (advancedVisible) { + FileSyncAdvancedSettings(configuration, onConfigurationChanged) + } + FileSyncNotice("If the app closes or the network drops, completed work is preserved and the remaining queue resumes safely.") +} + +@Composable +private fun FileSyncStepIntro(title: String, supporting: String) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + Text(title, style = MaterialTheme.typography.titleLarge) + Text(supporting, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun FileSyncSetupLocationRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + eyebrow: String, + title: String, + supporting: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Row( + modifier = Modifier.padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f)) { + Text(eyebrow, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(title, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(supporting, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (actionLabel != null && onAction != null) { + OutlinedButton(onClick = onAction) { Text(actionLabel) } + } + } + } +} + +@Composable +private fun FileSyncChoiceCard( + selected: Boolean, + title: String, + supporting: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + color = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Card), + border = BorderStroke( + if (selected) 2.dp else 1.dp, + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + ), + ) { + Row( + modifier = Modifier.padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(supporting, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (selected) Icon(NextcloudIcons.CheckCircle, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary) + } + } +} + +@Composable +private fun FileSyncScopeSummary(configuration: FileSyncConfiguration, syntheticScopeSummary: String?) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Text("Scope preview", style = MaterialTheme.typography.labelLarge) + syntheticScopeSummary?.let { + Text(it, style = MaterialTheme.typography.titleMedium) + } + Text( + if (configuration.selectedPaths.isEmpty()) "Whole folder included" else "${configuration.selectedPaths.size} selected paths", + style = MaterialTheme.typography.bodySmall, + ) + Text("${configuration.ignoredPatterns.size} ignore rules", style = MaterialTheme.typography.bodySmall) + Text( + if (configuration.priorityRules.isEmpty()) "Normal transfer order" else configuration.priorityRules.joinToString( + prefix = "Priority: ", + separator = " then ", + transform = { it.pattern.fileSyncFriendlyPattern() }, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun StructuredFileSyncRulesEditor( + configuration: FileSyncConfiguration, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, + onChooseSelectedPaths: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large)) { + FileSyncSelectionEditor( + values = configuration.selectedPaths, + onChoose = onChooseSelectedPaths, + onRemove = { removed -> + onConfigurationChanged( + configuration.copy(selectedPaths = configuration.selectedPaths - removed), + ) + }, + ) + FileSyncRuleListEditor( + title = "Ignored files", + supporting = "Temporary and generated files never enter the queue.", + placeholder = "**/Cache/**", + values = configuration.ignoredPatterns, + validate = { value -> runCatching { requireValidFileSyncGlob(value) }.isSuccess }, + onValuesChanged = { onConfigurationChanged(configuration.copy(ignoredPatterns = it)) }, + ) + FileSyncRuleListEditor( + title = "Transfer priority", + supporting = "Higher rows transfer first. This does not skip lower rows.", + placeholder = "**/*.raf", + values = configuration.priorityRules.map(FileSyncPriorityRule::pattern), + validate = { value -> runCatching { requireValidFileSyncGlob(value) }.isSuccess }, + reorderable = true, + onValuesChanged = { values -> + onConfigurationChanged(configuration.copy(priorityRules = values.map(::FileSyncPriorityRule))) + }, + ) + } +} + +@Composable +private fun FileSyncSelectionEditor( + values: List, + onChoose: () -> Unit, + onRemove: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text("Selected folders and files", style = MaterialTheme.typography.labelLarge) + Text( + "Choose verified items from the mapped Nextcloud folder. Leave empty to include everything.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + values.forEach { value -> + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Row( + modifier = Modifier.padding(start = NextcloudSpacing.Medium, end = NextcloudSpacing.XSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(NextcloudIcons.CheckCircle, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(NextcloudSpacing.Small)) + Text(value, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + TextButton(onClick = { onRemove(value) }) { Text("Remove") } + } + } + } + OutlinedButton(onClick = onChoose) { + Icon(NextcloudIcons.FolderOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(NextcloudSpacing.Small)) + Text(if (values.isEmpty()) "Choose folders or files" else "Change selection") + } + } +} + +@Composable +private fun FileSyncRuleListEditor( + title: String, + supporting: String, + placeholder: String, + values: List, + validate: (String) -> Boolean, + reorderable: Boolean = false, + onValuesChanged: (List) -> Unit, +) { + var draft by rememberSaveable(title) { mutableStateOf("") } + val normalizedDraft = draft.trim() + val canAdd = normalizedDraft.isNotEmpty() && normalizedDraft !in values && validate(normalizedDraft) + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text(title, style = MaterialTheme.typography.labelLarge) + Text(supporting, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + values.forEachIndexed { index, value -> + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Row( + modifier = Modifier.padding(start = NextcloudSpacing.Medium, end = NextcloudSpacing.XSmall), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(value.fileSyncFriendlyPattern(), modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + if (reorderable && index > 0) { + TextButton(onClick = { + val next = values.toMutableList() + val moved = next.removeAt(index) + next.add(index - 1, moved) + onValuesChanged(next) + }) { Text("Up") } + } + if (reorderable && index < values.lastIndex) { + TextButton(onClick = { + val next = values.toMutableList() + val moved = next.removeAt(index) + next.add(index + 1, moved) + onValuesChanged(next) + }) { Text("Down") } + } + TextButton(onClick = { onValuesChanged(values.filterIndexed { itemIndex, _ -> itemIndex != index }) }) { + Text("Remove") + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it.take(1_024) }, + modifier = Modifier.weight(1f), + label = { Text("Add rule") }, + placeholder = { Text(placeholder) }, + singleLine = true, + isError = normalizedDraft.isNotEmpty() && !canAdd, + ) + Button( + enabled = canAdd, + onClick = { + onValuesChanged(values + normalizedDraft) + draft = "" + }, + ) { Text("Add") } + } + } +} + +@Composable +private fun FileSyncAdvancedSettings( + configuration: FileSyncConfiguration, + onConfigurationChanged: (FileSyncConfiguration) -> Unit, +) { + var deviceLabelDraft by rememberSaveable { mutableStateOf(configuration.deviceLabel) } + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large)) { + FileSyncSettingChoices( + title = "When both copies changed", + options = FileSyncConflictPolicy.entries, + selected = configuration.conflictPolicy, + label = FileSyncConflictPolicy::syncConflictTitle, + onSelected = { onConfigurationChanged(configuration.copy(conflictPolicy = it)) }, + ) + FileSyncSettingChoices( + title = "When a file was deleted", + options = FileSyncDeletionPolicy.entries, + selected = configuration.deletionPolicy, + label = FileSyncDeletionPolicy::syncDeletionTitle, + onSelected = { onConfigurationChanged(configuration.copy(deletionPolicy = it)) }, + ) + FileSyncSettingChoices( + title = "Connection", + options = FileSyncNetworkPolicy.entries, + selected = configuration.networkPolicy, + label = FileSyncNetworkPolicy::syncNetworkTitle, + onSelected = { onConfigurationChanged(configuration.copy(networkPolicy = it)) }, + ) + FileSyncSettingChoices( + title = "Power", + options = FileSyncPowerPolicy.entries, + selected = configuration.powerPolicy, + label = FileSyncPowerPolicy::syncPowerTitle, + onSelected = { onConfigurationChanged(configuration.copy(powerPolicy = it)) }, + ) + OutlinedTextField( + value = deviceLabelDraft, + onValueChange = { value -> + deviceLabelDraft = value.take(128) + if (deviceLabelDraft.isNotBlank()) { + onConfigurationChanged(configuration.copy(deviceLabel = deviceLabelDraft)) + } + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Device label for conflict copies") }, + singleLine = true, + isError = deviceLabelDraft.isBlank(), + supportingText = if (deviceLabelDraft.isBlank()) { + { Text("Enter a device label before continuing.") } + } else { + null + }, + ) + } +} + +@Composable +private fun FileSyncSettingChoices( + title: String, + options: List, + selected: T, + label: (T) -> String, + onSelected: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small)) { + Text(title, style = MaterialTheme.typography.labelLarge) + options.forEach { option -> + FileSyncChoiceCard( + selected = option == selected, + title = label(option), + supporting = if (option == selected) "Selected" else "Tap to select", + icon = if (option == selected) NextcloudIcons.CheckCircle else NextcloudIcons.Info, + onClick = { onSelected(option) }, + ) + } + } +} + +@Composable +private fun FileSyncReviewRow(label: String, value: String) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, modifier = Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun FileSyncSetupFooter( + step: FileSyncSetupStep, + busy: Boolean, + configuration: FileSyncConfiguration, + mediaReady: Boolean, + onBack: () -> Unit, + onNext: () -> Unit, + onAdd: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + if (step != FileSyncSetupStep.Locations) { + OutlinedButton(enabled = !busy, onClick = onBack) { Text("Back") } + } + if (step != FileSyncSetupStep.Review) { + Button(enabled = !busy, onClick = onNext) { Text("Continue") } + } else { + Button( + enabled = !busy && configuration.deviceLabel.isNotBlank() && mediaReady, + onClick = onAdd, + ) { + if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("Add sync") + } + } + } +} + +private fun FileSyncRulePreset.matches(configuration: FileSyncConfiguration): Boolean = when (this) { + FileSyncRulePreset.Everything -> configuration.selectedPaths.isEmpty() && + configuration.ignoredPatterns.isEmpty() && configuration.priorityRules.isEmpty() + FileSyncRulePreset.PhotoRawFirst -> configuration.selectedPaths.isEmpty() && + configuration.priorityRules.map(FileSyncPriorityRule::pattern) == listOf("**/*.raf", "**/*.jpg", "**/*.jpeg") + FileSyncRulePreset.ChooseFolders -> configuration.selectedPaths.isNotEmpty() +} + +private fun FileSyncRulePreset.applyTo(configuration: FileSyncConfiguration): FileSyncConfiguration = when (this) { + FileSyncRulePreset.Everything -> configuration.copy( + selectedPaths = emptyList(), + ignoredPatterns = emptyList(), + priorityRules = emptyList(), + ) + FileSyncRulePreset.PhotoRawFirst -> configuration.copy( + selectedPaths = emptyList(), + ignoredPatterns = listOf("*.part", "**/.thumbnails/**", "**/Cache/**"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + FileSyncPriorityRule("**/*.jpeg"), + ), + ) + FileSyncRulePreset.ChooseFolders -> configuration +} + +private fun FileSyncDirection.syncDirectionTitle(): String = when (this) { + FileSyncDirection.Bidirectional -> "Two-way" + FileSyncDirection.UploadOnly -> "Device to Nextcloud" + FileSyncDirection.DownloadOnly -> "Nextcloud to device" +} + +private fun FileSyncDirection.syncDirectionDescription(): String = when (this) { + FileSyncDirection.Bidirectional -> "Changes on either side are copied to the other side." + FileSyncDirection.UploadOnly -> "Changes from this device upload; Nextcloud never writes back." + FileSyncDirection.DownloadOnly -> "Changes from Nextcloud download; local changes never upload." +} + +private fun FileSyncDirection.syncDirectionGlyph(): String = when (this) { + FileSyncDirection.Bidirectional -> "<->" + FileSyncDirection.UploadOnly -> "->" + FileSyncDirection.DownloadOnly -> "<-" +} + +private fun FileSyncPairSummary.queuedLabel(): String = when { + runningCount > 0 -> "$runningCount active" + readyCount > 0 -> "$readyCount ready" + else -> "None" +} + +private fun FileSyncPairSummary.selectionSummary(): String = if (configuration.selectedPaths.isEmpty()) { + "Everything in this folder" +} else { + "${configuration.selectedPaths.size} selected paths" +} + +private fun FileSyncPairSummary.ignoreSummary(): String = if (configuration.ignoredPatterns.isEmpty()) { + "No ignored patterns" +} else { + "Ignore ${configuration.ignoredPatterns.size} patterns" +} + +private fun FileSyncPairSummary.prioritySummary(): String = if (configuration.priorityRules.isEmpty()) { + "Normal transfer priority" +} else { + configuration.priorityRules.joinToString( + prefix = "Priority: ", + separator = " then ", + transform = { it.pattern.fileSyncFriendlyPattern() }, + ) +} + +private fun String.fileSyncFriendlyPattern(): String = when (lowercase()) { + "**/*.raf" -> "RAW (.raf)" + "**/*.jpg" -> "JPEG (.jpg)" + "**/*.jpeg" -> "JPEG (.jpeg)" + else -> this +} + +private fun FileSyncConflictPolicy.syncConflictTitle(): String = when (this) { + FileSyncConflictPolicy.Ask -> "Ask before changing either copy" + FileSyncConflictPolicy.KeepBoth -> "Keep both copies" + FileSyncConflictPolicy.PreferLocal -> "Prefer this device" + FileSyncConflictPolicy.PreferRemote -> "Prefer Nextcloud" +} + +private fun FileSyncDeletionPolicy.syncDeletionTitle(): String = when (this) { + FileSyncDeletionPolicy.Ask -> "Ask before deleting the other copy" + FileSyncDeletionPolicy.Propagate -> "Delete the other copy" + FileSyncDeletionPolicy.RestoreMissing -> "Restore the missing copy" +} + +private fun FileSyncNetworkPolicy.syncNetworkTitle(): String = when (this) { + FileSyncNetworkPolicy.AnyConnection -> "Wi-Fi or mobile data" + FileSyncNetworkPolicy.Unmetered -> "Unmetered network only" +} + +private fun FileSyncPowerPolicy.syncPowerTitle(): String = when (this) { + FileSyncPowerPolicy.AnyPower -> "Any battery level" + FileSyncPowerPolicy.BatteryNotLow -> "Pause when battery is low" + FileSyncPowerPolicy.Charging -> "Only while charging" +} + +private fun FileSyncDecisionReason.syncDecisionReasonTitle(): String = when (this) { + FileSyncDecisionReason.FirstSyncCollision -> "Both folders already contain this path." + FileSyncDecisionReason.SimultaneousEdit -> "Both copies changed since the last completed sync." + FileSyncDecisionReason.LocalDeletion -> "The device copy was deleted." + FileSyncDecisionReason.RemoteDeletion -> "The Nextcloud copy was deleted." + FileSyncDecisionReason.TypeChanged -> "One side is a file and the other is a folder." +} + +private fun FileSyncDecisionChoice.syncDecisionTitle(): String = when (this) { + FileSyncDecisionChoice.UseLocal -> "Use device copy" + FileSyncDecisionChoice.UseRemote -> "Use Nextcloud copy" + FileSyncDecisionChoice.KeepBoth -> "Keep both copies" + FileSyncDecisionChoice.PropagateDeletion -> "Delete other copy" + FileSyncDecisionChoice.RestoreMissing -> "Restore missing copy" + FileSyncDecisionChoice.Skip -> "Skip this version" +} + +private fun Long.fileSyncBytes(): String = when { + this >= 1024L * 1024L * 1024L -> "${this / (1024L * 1024L * 1024L)} GB" + this >= 1024L * 1024L -> "${this / (1024L * 1024L)} MB" + this >= 1024L -> "${this / 1024L} KB" + else -> "$this B" +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt index f9ea61b5e..33c10fe5d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt @@ -1,11 +1,15 @@ package dev.obiente.nextcloudnative.app +import kotlinx.serialization.Serializable + +@Serializable enum class FileSyncDirection { Bidirectional, DownloadOnly, UploadOnly, } +@Serializable enum class FileSyncConflictPolicy { Ask, KeepBoth, @@ -13,23 +17,41 @@ enum class FileSyncConflictPolicy { PreferRemote, } +@Serializable enum class FileSyncDeletionPolicy { Ask, Propagate, RestoreMissing, } +@Serializable enum class FileSyncNetworkPolicy { AnyConnection, Unmetered, } +@Serializable enum class FileSyncPowerPolicy { AnyPower, BatteryNotLow, Charging, } +/** + * One ordered transfer-priority group. The first matching rule wins. + * + * Patterns use portable path globs: `*` and `?` match inside one path segment and `**` matches + * across directories. A pattern without `/` matches a name at any depth. Matching is deliberately + * case-insensitive so camera extensions such as `.RAF` and `.raf` share one policy on every + * platform; path identity itself remains case-preserving and platform-specific. + */ +@Serializable +data class FileSyncPriorityRule(val pattern: String) { + init { + requireValidFileSyncGlob(pattern) + } +} + enum class SyncEntryKind { File, Directory } data class LocalSyncEntry( @@ -103,6 +125,7 @@ data class FileSyncBaseline( } } +@Serializable data class FileSyncConfiguration( val direction: FileSyncDirection = FileSyncDirection.Bidirectional, val conflictPolicy: FileSyncConflictPolicy = FileSyncConflictPolicy.Ask, @@ -110,10 +133,109 @@ data class FileSyncConfiguration( val deviceLabel: String, val networkPolicy: FileSyncNetworkPolicy = FileSyncNetworkPolicy.AnyConnection, val powerPolicy: FileSyncPowerPolicy = FileSyncPowerPolicy.BatteryNotLow, + val selectedPaths: List = emptyList(), + val ignoredPatterns: List = emptyList(), + val priorityRules: List = emptyList(), ) { init { require(deviceLabel.isNotBlank()) + require(selectedPaths.size <= MAX_FILE_SYNC_SELECTION_PATHS) + require(ignoredPatterns.size <= MAX_FILE_SYNC_FILTER_PATTERNS) + require(priorityRules.size <= MAX_FILE_SYNC_PRIORITY_RULES) + selectedPaths.forEach(::requireValidSyncPath) + ignoredPatterns.forEach(::requireValidFileSyncGlob) + require(selectedPaths.distinct() == selectedPaths) { "Selective sync paths must be unique." } + require(ignoredPatterns.distinct() == ignoredPatterns) { "Ignore patterns must be unique." } + require(priorityRules.distinct() == priorityRules) { "Priority rules must be unique." } + } +} + +/** True when [relativePath] belongs to the configured selective-sync view and is not ignored. */ +fun FileSyncConfiguration.includesSyncPath( + relativePath: String, + kind: SyncEntryKind, +): Boolean { + requireValidSyncPath(relativePath) + val pathSegments = relativePath.split('/') + val pathAndParents = pathSegments.indices.map { endIndex -> + pathSegments.take(endIndex + 1).joinToString("/") + } + if (ignoredPatterns.any { pattern -> + pathAndParents.any { candidate -> fileSyncGlobMatches(pattern, candidate) } + } + ) { + return false + } + if (selectedPaths.isEmpty()) return true + return selectedPaths.any { selected -> + relativePath == selected || + relativePath.startsWith("$selected/") || + (kind == SyncEntryKind.Directory && selected.startsWith("$relativePath/")) + } +} + +/** Zero-based ordered priority group, with unmatched files after every configured group. */ +fun FileSyncConfiguration.fileSyncPriority(relativePath: String): Int { + requireValidSyncPath(relativePath) + return priorityRules.indexOfFirst { fileSyncGlobMatches(it.pattern, relativePath) } + .takeIf { it >= 0 } + ?: priorityRules.size +} + +fun fileSyncGlobMatches(pattern: String, relativePath: String): Boolean { + requireValidFileSyncGlob(pattern) + requireValidSyncPath(relativePath) + val patternSegments = pattern.lowercase().split('/') + val pathSegments = relativePath.lowercase().split('/') + if (patternSegments.size == 1) { + return pathSegments.any { segment -> matchFileSyncSegment(patternSegments.single(), segment) } } + val memo = mutableMapOf, Boolean>() + fun match(patternIndex: Int, pathIndex: Int): Boolean = memo.getOrPut(patternIndex to pathIndex) { + when { + patternIndex == patternSegments.size -> pathIndex == pathSegments.size + patternSegments[patternIndex] == "**" -> + match(patternIndex + 1, pathIndex) || + (pathIndex < pathSegments.size && match(patternIndex, pathIndex + 1)) + pathIndex == pathSegments.size -> false + else -> matchFileSyncSegment(patternSegments[patternIndex], pathSegments[pathIndex]) && + match(patternIndex + 1, pathIndex + 1) + } + } + return match(0, 0) +} + +private fun matchFileSyncSegment(pattern: String, value: String): Boolean { + var previous = BooleanArray(value.length + 1) + previous[0] = true + pattern.forEach { token -> + val current = BooleanArray(value.length + 1) + when (token) { + '*' -> { + current[0] = previous[0] + for (index in 1..value.length) { + current[index] = previous[index] || current[index - 1] + } + } + '?' -> { + for (index in 1..value.length) current[index] = previous[index - 1] + } + else -> { + for (index in 1..value.length) { + current[index] = previous[index - 1] && token == value[index - 1] + } + } + } + previous = current + } + return previous[value.length] +} + +internal fun requireValidFileSyncGlob(pattern: String) { + require(pattern.isNotBlank() && pattern.length <= MAX_FILE_SYNC_GLOB_LENGTH) + require(!pattern.startsWith('/') && !pattern.endsWith('/')) + require('\\' !in pattern && pattern.none(Char::isISOControl)) + require(pattern.split('/').all { it.isNotBlank() && it != "." && it != ".." }) } sealed interface FileSyncOperation { @@ -269,22 +391,34 @@ private fun planDirectory( local == null && remote != null -> when (configuration.deletionPolicy) { FileSyncDeletionPolicy.Ask -> FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.LocalDeletion) - FileSyncDeletionPolicy.Propagate -> + FileSyncDeletionPolicy.Propagate -> if (configuration.hasPartialDirectoryView()) { + FileSyncOperation.Skipped(path, PARTIAL_DIRECTORY_DELETION_REASON) + } else { FileSyncOperation.DeleteRemote(path, remote.etag) + } FileSyncDeletionPolicy.RestoreMissing -> FileSyncOperation.Download(path, null) } remote == null && local != null -> when (configuration.deletionPolicy) { FileSyncDeletionPolicy.Ask -> FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.RemoteDeletion) - FileSyncDeletionPolicy.Propagate -> + FileSyncDeletionPolicy.Propagate -> if (configuration.hasPartialDirectoryView()) { + FileSyncOperation.Skipped(path, PARTIAL_DIRECTORY_DELETION_REASON) + } else { FileSyncOperation.DeleteLocal(path, local.revision) + } FileSyncDeletionPolicy.RestoreMissing -> FileSyncOperation.Upload(path, null) } else -> null } +private fun FileSyncConfiguration.hasPartialDirectoryView(): Boolean = + selectedPaths.isNotEmpty() || ignoredPatterns.isNotEmpty() + +private const val PARTIAL_DIRECTORY_DELETION_REASON = + "Directory deletion is paused because selective or ignored items may exist below it." + private fun planFirstSync( path: String, local: LocalSyncEntry?, @@ -315,9 +449,16 @@ private fun planLocalDeletion( configuration: FileSyncConfiguration, ): FileSyncOperation? { if (remote == null) return null - if (configuration.direction == FileSyncDirection.DownloadOnly || remoteChanged) { + if (configuration.direction == FileSyncDirection.DownloadOnly) { return FileSyncOperation.Download(path, null) } + if (remoteChanged) { + return if (configuration.direction == FileSyncDirection.UploadOnly) { + FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.LocalDeletion) + } else { + FileSyncOperation.Download(path, null) + } + } return when (configuration.deletionPolicy) { FileSyncDeletionPolicy.Ask -> FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.LocalDeletion) FileSyncDeletionPolicy.Propagate -> FileSyncOperation.DeleteRemote(path, remote.etag) @@ -332,9 +473,16 @@ private fun planRemoteDeletion( localChanged: Boolean, configuration: FileSyncConfiguration, ): FileSyncOperation? { - if (configuration.direction == FileSyncDirection.UploadOnly || localChanged) { + if (configuration.direction == FileSyncDirection.UploadOnly) { return FileSyncOperation.Upload(path, null) } + if (localChanged) { + return if (configuration.direction == FileSyncDirection.DownloadOnly) { + FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.RemoteDeletion) + } else { + FileSyncOperation.Upload(path, null) + } + } return when (configuration.deletionPolicy) { FileSyncDeletionPolicy.Ask -> FileSyncOperation.NeedsDecision(path, FileSyncDecisionReason.RemoteDeletion) FileSyncDeletionPolicy.Propagate -> FileSyncOperation.DeleteLocal(path, local.revision) @@ -390,6 +538,10 @@ private fun requireUniqueSyncPaths(paths: List, source: String) { } private const val SHA_256_HEX_LENGTH = 64 +internal const val MAX_FILE_SYNC_SELECTION_PATHS = 256 +internal const val MAX_FILE_SYNC_FILTER_PATTERNS = 256 +internal const val MAX_FILE_SYNC_PRIORITY_RULES = 64 +internal const val MAX_FILE_SYNC_GLOB_LENGTH = 1_024 internal fun requireValidSyncPath(path: String) { require(path.isNotBlank() && !path.startsWith('/') && !path.endsWith('/')) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt index 6c07225af..11269c539 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -14,7 +15,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap @@ -74,6 +78,60 @@ enum class MarketingCaptureScenario( MarketingCapturePurpose.Showcase, "mobile", "phone-portrait", width = 1_080, height = 2_200, density = 2.625f, ), + FileSyncRulesMobile( + "file-sync-rules-mobile", "file-sync-rules-mobile.png", NextcloudPresentation.Adaptive, + "File sync", "Folder pair configuration", "Selective, ignore, and priority rules", + MarketingCapturePurpose.StateCoverage, "mobile", "phone-portrait", + width = 1_080, height = 2_200, density = 2.625f, + ), + FileSyncStatusMobile( + "file-sync-status-mobile", "file-sync-status-mobile.png", NextcloudPresentation.Adaptive, + "File sync", "Folder sync center", "Conflict recovery and mapping health", + MarketingCapturePurpose.StateCoverage, "mobile", "phone-portrait", + width = 1_080, height = 2_200, density = 2.625f, + ), + FileSyncStatusDesktop( + "file-sync-status-desktop", "file-sync-status-desktop.png", NextcloudPresentation.Desktop, + "File sync", "Folder sync center", "Priority queue, conflict, and failure", + MarketingCapturePurpose.StateCoverage, "linux", "wide", + width = 1_440, height = 900, density = 1f, + ), + FileSyncSetupDesktop( + "file-sync-setup-desktop", "file-sync-setup-desktop.png", NextcloudPresentation.Desktop, + "File sync", "Folder pair configuration", "Guided RAW-first setup", + MarketingCapturePurpose.StateCoverage, "linux", "wide", + width = 1_440, height = 900, density = 1f, + ), + FileSyncSelectionDesktop( + "file-sync-selection-desktop", "file-sync-selection-desktop.png", NextcloudPresentation.Desktop, + "File sync", "Selective sync browser", "Verified folders and files", + MarketingCapturePurpose.StateCoverage, "linux", "wide", + width = 1_440, height = 900, density = 1f, + ), + FileSyncSelectionMobile( + "file-sync-selection-mobile", "file-sync-selection-mobile.png", NextcloudPresentation.Adaptive, + "File sync", "Selective sync browser", "Verified folders and files", + MarketingCapturePurpose.StateCoverage, "mobile", "phone-portrait", + width = 1_080, height = 2_200, density = 2.625f, + ), + VirtualFileStorageMobile( + "virtual-file-storage-mobile", "virtual-file-storage-mobile.png", NextcloudPresentation.Adaptive, + "Virtual files", "Storage rules", "Automatic cleanup with protected pins", + MarketingCapturePurpose.StateCoverage, "mobile", "phone-portrait", + width = 1_080, height = 2_200, density = 2.625f, + ), + VirtualFileStorageDesktop( + "virtual-file-storage-desktop", "virtual-file-storage-desktop.png", NextcloudPresentation.Desktop, + "Virtual files", "Storage overview", "Hydrated cache, pins, and free-up action", + MarketingCapturePurpose.StateCoverage, "linux", "wide", + width = 1_440, height = 900, density = 1f, + ), + DesktopStartupSettings( + "desktop-startup-settings", "desktop-startup-settings.png", NextcloudPresentation.Desktop, + "File sync", "Desktop settings", "Start on login enabled", + MarketingCapturePurpose.StateCoverage, "desktop", "wide", + width = 1_440, height = 900, density = 1f, + ), AdaptiveApp( "adaptive-dynamic-data", "adaptive-dynamic-data.png", NextcloudPresentation.Desktop, "Dynamic apps", "Nested collection and semantic form", "Synthetic visual QA", @@ -694,6 +752,445 @@ internal fun MarketingMediaBackupScenario() { } } +@Composable +internal fun MarketingFileSyncRulesScenario() { + var configuration by remember { + mutableStateOf( + FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + conflictPolicy = FileSyncConflictPolicy.Ask, + deletionPolicy = FileSyncDeletionPolicy.Ask, + deviceLabel = "Alex's phone", + networkPolicy = FileSyncNetworkPolicy.Unmetered, + powerPolicy = FileSyncPowerPolicy.BatteryNotLow, + ignoredPatterns = listOf("*.part", "**/.thumbnails/**", "**/Cache/**"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + FileSyncPriorityRule("**/*.jpeg"), + ), + ), + ) + } + FileSyncSetupSurface( + localRoot = FileSyncLocalRoot("fixture-studio-local", "Pictures/Studio"), + mediaSuggestion = null, + remotePath = "Photos/Studio", + configuration = configuration, + mediaPreview = null, + mediaPreviewLoading = false, + mediaPreviewError = null, + busy = false, + onDismiss = {}, + onChooseDestination = {}, + onConfigurationChanged = { configuration = it }, + onAdd = {}, + modifier = Modifier.fillMaxSize(), + initialStep = FileSyncSetupStep.Rules, + syntheticScopeSummary = "18,742 files - 123.4 GB - 2,511 RAW", + ) +} + +@Composable +internal fun MarketingFileSyncStatusDesktopScenario() { + Column(modifier = Modifier.fillMaxSize()) { + ScreenHeader( + title = "Folder sync", + subtitle = "Linux workstation", + onBack = {}, + ) + Column( + modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + FileSyncWorkspace( + snapshot = FileSyncCenterSnapshot( + support = FileSyncCenterSupport.Available, + limitation = "Automatic background desktop scheduling is not enabled yet. Use Sync now.", + pairs = listOf( + FileSyncPairSummary( + id = "fixture-studio", + localDisplayName = "Studio archive", + localRootPath = "~/Pictures/Studio", + remoteRootPath = "Photos/Studio", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + deviceLabel = "Field workstation", + selectedPaths = listOf("Shoots/2026", "Exports/Portfolio"), + ignoredPatterns = listOf("*.part", "**/.thumbnails/**"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + ), + ), + readyCount = 5, + runningCount = 1, + conflicts = emptyList(), + failedCount = 0, + skippedCount = 0, + completedCount = 341, + lastScanEpochMillis = 1, + scheduleDescription = "Manual sync on this desktop", + ), + FileSyncPairSummary( + id = "fixture-client", + localDisplayName = "Client selects", + localRootPath = "~/Pictures/Clients/Selects", + remoteRootPath = "Photos/Clients/Selects", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + deviceLabel = "Field workstation", + ignoredPatterns = listOf("*.part"), + ), + readyCount = 5, + runningCount = 0, + conflicts = listOf( + FileSyncConflictSummary( + workId = 42, + relativePath = "cover.jpg", + reason = FileSyncDecisionReason.SimultaneousEdit, + choices = setOf( + FileSyncDecisionChoice.UseLocal, + FileSyncDecisionChoice.UseRemote, + FileSyncDecisionChoice.KeepBoth, + FileSyncDecisionChoice.Skip, + ), + ), + ), + failedCount = 0, + skippedCount = 0, + completedCount = 86, + lastScanEpochMillis = 1, + scheduleDescription = "Manual sync on this desktop", + ), + FileSyncPairSummary( + id = "fixture-documents", + localDisplayName = "Project documents", + localRootPath = "~/Nextcloud/Projects", + remoteRootPath = "Work/Projects", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.UploadOnly, + deviceLabel = "Field workstation", + ignoredPatterns = listOf("*.tmp"), + ), + readyCount = 0, + runningCount = 0, + conflicts = emptyList(), + failedCount = 0, + skippedCount = 0, + completedCount = 219, + lastScanEpochMillis = 1, + scheduleDescription = "Manual sync on this desktop", + ), + FileSyncPairSummary( + id = "fixture-archive", + localDisplayName = "Archive 2024", + localRootPath = "~/Pictures/Archive/2024", + remoteRootPath = "Photos/Archive/2024", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.DownloadOnly, + deviceLabel = "Field workstation", + ), + readyCount = 12, + runningCount = 0, + conflicts = emptyList(), + failedCount = 1, + skippedCount = 0, + completedCount = 802, + lastScanEpochMillis = 1, + scheduleDescription = "Will resume when Nextcloud is reachable", + ), + ), + ), + loading = false, + busyPairId = null, + onAdd = {}, + onRun = {}, + onRemove = {}, + onResolve = { _, _, _ -> }, + initialSelectedPairId = "fixture-client", + ) + } + } +} + +@Composable +internal fun MarketingFileSyncSetupDesktopScenario() { + var configuration by remember { + mutableStateOf( + FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + conflictPolicy = FileSyncConflictPolicy.Ask, + deletionPolicy = FileSyncDeletionPolicy.Ask, + deviceLabel = "Field workstation", + networkPolicy = FileSyncNetworkPolicy.AnyConnection, + powerPolicy = FileSyncPowerPolicy.BatteryNotLow, + ignoredPatterns = listOf("*.part", "**/.thumbnails/**", "**/Cache/**"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + FileSyncPriorityRule("**/*.jpeg"), + ), + ), + ) + } + Box(modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), contentAlignment = Alignment.Center) { + FileSyncSetupSurface( + localRoot = FileSyncLocalRoot("fixture-desktop-studio", "~/Pictures/Studio"), + mediaSuggestion = null, + remotePath = "Photos/Studio", + configuration = configuration, + mediaPreview = null, + mediaPreviewLoading = false, + mediaPreviewError = null, + busy = false, + onDismiss = {}, + onChooseDestination = {}, + onConfigurationChanged = { configuration = it }, + onAdd = {}, + modifier = Modifier.fillMaxWidth().widthIn(max = 920.dp).heightIn(max = 760.dp), + initialStep = FileSyncSetupStep.Rules, + syntheticScopeSummary = "18,742 files - 123.4 GB - 2,511 RAW", + ) + } +} + +@Composable +internal fun MarketingFileSyncSelectionScenario(services: NextcloudPlatformServices) { + Box( + modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), + contentAlignment = Alignment.Center, + ) { + RemoteFileSyncSelectionDialog( + services = services, + session = NextcloudSession("https://cloud.invalid", "alex@example.invalid", "fixture"), + userId = "alex", + remoteRootPath = "Photos/Studio", + initialSelection = listOf("RAW/Day 1"), + onDismiss = {}, + onSelected = {}, + embedded = true, + ) + } +} + +@Composable +internal fun MarketingFileSyncStatusMobileScenario() { + Column(modifier = Modifier.fillMaxSize()) { + ScreenHeader( + title = "Folder sync", + subtitle = "Alex's phone", + onBack = {}, + ) + Column( + modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.Medium), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + FileSyncWorkspace( + snapshot = FileSyncCenterSnapshot( + support = FileSyncCenterSupport.Available, + limitation = "Background sync resumes automatically when network and power rules allow it.", + pairs = listOf( + FileSyncPairSummary( + id = "fixture-mobile-studio", + localDisplayName = "Studio archive", + localRootPath = "Pictures/Studio", + remoteRootPath = "Photos/Studio", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + deviceLabel = "Alex's phone", + ignoredPatterns = listOf("*.part", "**/.thumbnails/**", "**/Cache/**"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + ), + ), + readyCount = 17, + runningCount = 1, + conflicts = emptyList(), + failedCount = 0, + skippedCount = 0, + completedCount = 341, + lastScanEpochMillis = 1, + scheduleDescription = "Background sync enabled", + ), + FileSyncPairSummary( + id = "fixture-mobile-client", + localDisplayName = "Client selects", + localRootPath = "Pictures/Clients/Selects", + remoteRootPath = "Photos/Clients/Selects", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.Bidirectional, + deviceLabel = "Alex's phone", + ignoredPatterns = listOf("*.part"), + ), + readyCount = 5, + runningCount = 0, + conflicts = listOf( + FileSyncConflictSummary( + workId = 52, + relativePath = "cover.jpg", + reason = FileSyncDecisionReason.SimultaneousEdit, + choices = setOf( + FileSyncDecisionChoice.UseLocal, + FileSyncDecisionChoice.UseRemote, + FileSyncDecisionChoice.KeepBoth, + FileSyncDecisionChoice.Skip, + ), + ), + ), + failedCount = 0, + skippedCount = 0, + completedCount = 86, + lastScanEpochMillis = 1, + scheduleDescription = "Waiting for your decision", + ), + FileSyncPairSummary( + id = "fixture-mobile-camera", + localDisplayName = "Camera backup", + localRootPath = "DCIM/Camera", + remoteRootPath = "Photos/Phone camera", + configuration = FileSyncConfiguration( + direction = FileSyncDirection.UploadOnly, + deviceLabel = "Alex's phone", + networkPolicy = FileSyncNetworkPolicy.Unmetered, + ), + readyCount = 0, + runningCount = 0, + conflicts = emptyList(), + failedCount = 0, + skippedCount = 0, + completedCount = 1_842, + lastScanEpochMillis = 1, + scheduleDescription = "Wi-Fi only", + ), + ), + ), + loading = false, + busyPairId = null, + onAdd = {}, + onRun = {}, + onRemove = {}, + onResolve = { _, _, _ -> }, + initialSelectedPairId = "fixture-mobile-client", + ) + } + } +} + +@Composable +internal fun MarketingVirtualFileStorageMobileScenario() { + val snapshot = remember { + marketingVirtualFileStorageSnapshot( + support = VirtualFileStorageSupport.Available, + integration = VirtualFilePlatformIntegration.AndroidDocumentsProvider, + ) + } + var policy by remember { mutableStateOf(snapshot.policy) } + Column(modifier = Modifier.fillMaxSize()) { + ScreenHeader( + title = "Virtual file storage", + subtitle = "Cache and automatic cleanup", + onBack = {}, + ) + VirtualFileStoragePolicyEditor( + snapshot = snapshot, + busy = false, + policy = policy, + onPolicyChanged = { policy = it }, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = PaddingValues(NextcloudSpacing.XLarge), + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 4.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.TextButton(onClick = {}) { Text("Cancel") } + androidx.compose.material3.Button(onClick = {}) { Text("Save rules") } + } + } + } +} + +@Composable +internal fun MarketingVirtualFileStorageDesktopScenario() { + Column(modifier = Modifier.fillMaxSize()) { + ScreenHeader( + title = "Sync & offline", + subtitle = "Virtual files and device storage", + onBack = {}, + ) + Box( + modifier = Modifier.fillMaxSize().padding(NextcloudSpacing.XLarge), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = Modifier.widthIn(max = 920.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + Text( + "Keep the whole cloud visible in your file manager", + style = MaterialTheme.typography.headlineSmall, + ) + Text( + "Opened files stay fast in a managed cache. Pins remain offline, while safe " + + "cleanup protects edits, transfers, conflicts, and files in use.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + VirtualFileStorageCard( + snapshot = marketingVirtualFileStorageSnapshot( + support = VirtualFileStorageSupport.Available, + integration = VirtualFilePlatformIntegration.WindowsCloudFiles, + ), + loading = false, + busy = false, + onManage = {}, + onFreeUp = {}, + onActivateProvider = {}, + onDeactivateProvider = {}, + ) + } + } + } +} + +private fun marketingVirtualFileStorageSnapshot( + support: VirtualFileStorageSupport, + integration: VirtualFilePlatformIntegration, +): VirtualFileStorageSnapshot = VirtualFileStorageSnapshot( + support = support, + integration = integration, + policy = VirtualFileCachePolicy( + automaticCleanup = true, + maximumCacheBytes = 20L * 1024L * 1024L * 1024L, + minimumFreeSpaceBytes = 10L * 1024L * 1024L * 1024L, + unusedFileAgeMillis = 30L * 24L * 60L * 60L * 1_000L, + ), + cachedBytes = 12_884_901_888L, + reclaimableBytes = 7_193_722_880L, + pinnedBytes = 4_482_344_960L, + hydratedFileCount = 1_842, + pinnedFileCount = 318, + availableFreeBytes = 68_719_476_736L, + storageCapacityBytes = 512L * 1024L * 1024L * 1024L, + limitations = emptyList(), + providerState = VirtualFileProviderState.Active, + providerLocation = when (integration) { + VirtualFilePlatformIntegration.AndroidDocumentsProvider -> "System Files / Nextcloud Native" + VirtualFilePlatformIntegration.WindowsCloudFiles -> "Nextcloud Native in File Explorer" + VirtualFilePlatformIntegration.LinuxFilesystemMount -> "~/Nextcloud Native" + VirtualFilePlatformIntegration.AppleFileProvider -> "Files / Nextcloud Native" + VirtualFilePlatformIntegration.InAppOnDemandCache -> null + }, +) + @Composable internal fun MarketingAdaptiveAppScenario(scenario: MarketingCaptureScenario) { require( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 38832178f..755f56731 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -831,6 +831,18 @@ fun NextcloudNativeMarketingCapture( -> MarketingPhotoFolderScenario(scenario, assets) MarketingCaptureScenario.ObsidianSync -> MarketingObsidianSyncScenario() MarketingCaptureScenario.MediaBackup -> MarketingMediaBackupScenario() + MarketingCaptureScenario.FileSyncRulesMobile -> MarketingFileSyncRulesScenario() + MarketingCaptureScenario.FileSyncStatusMobile -> MarketingFileSyncStatusMobileScenario() + MarketingCaptureScenario.FileSyncStatusDesktop -> MarketingFileSyncStatusDesktopScenario() + MarketingCaptureScenario.FileSyncSetupDesktop -> MarketingFileSyncSetupDesktopScenario() + MarketingCaptureScenario.FileSyncSelectionDesktop, + MarketingCaptureScenario.FileSyncSelectionMobile, + -> + MarketingFileSyncSelectionScenario(assets.services) + MarketingCaptureScenario.VirtualFileStorageMobile -> MarketingVirtualFileStorageMobileScenario() + MarketingCaptureScenario.VirtualFileStorageDesktop -> MarketingVirtualFileStorageDesktopScenario() + MarketingCaptureScenario.DesktopStartupSettings -> + MarketingDesktopStartupSettingsScenario(fixture, assets) MarketingCaptureScenario.RawPreviewLoadingMobile, MarketingCaptureScenario.RawPreviewErrorMobile, MarketingCaptureScenario.RawPreviewMemoriesReadyMobile, @@ -858,6 +870,81 @@ fun NextcloudNativeMarketingCapture( } } +@Composable +private fun MarketingDesktopStartupSettingsScenario( + fixture: MarketingDemoFixture, + assets: MarketingCaptureAssets, +) { + RootShell( + presentation = NextcloudPresentation.Desktop, + selected = NextcloudDestination.Settings, + onSelected = {}, + identity = NextcloudDesktopIdentity( + displayName = fixture.displayName, + cloudName = fixture.cloudName, + avatar = assets.avatar, + ), + ) { + Column(modifier = Modifier.fillMaxSize()) { + ProductHeader(title = "Settings", showSettings = false) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XLarge), + ) { + item { + SectionTitle("Appearance") + Row( + modifier = Modifier.padding(top = NextcloudSpacing.Medium), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + ThemePreference.entries.forEach { preference -> + FilterChip( + selected = preference == ThemePreference.System, + onClick = {}, + label = { Text(preference.name) }, + ) + } + } + } + item { + SectionTitle("Desktop") + DesktopStartOnLoginSettingsCard( + enabled = true, + message = null, + onEnabledChanged = {}, + ) + } + item { + SectionTitle("Files") + Surface( + modifier = Modifier.fillMaxWidth().padding(top = NextcloudSpacing.Medium), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + ) { + Icon(NextcloudIcons.Cloud, contentDescription = null) + Column(modifier = Modifier.weight(1f)) { + Text("Sync and offline", style = MaterialTheme.typography.titleMedium) + Text( + "Folder sync, virtual files, conflicts, and storage", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(NextcloudIcons.ChevronRight, contentDescription = null) + } + } + } + } + } + } +} + @Composable private fun LoginScreen( services: NextcloudPlatformServices, @@ -10252,6 +10339,8 @@ private fun SettingsScreen( val scope = rememberCoroutineScope() var loggingOut by remember { mutableStateOf(false) } var capabilityRefresh by remember { mutableStateOf(0) } + var startOnLogin by remember(services) { mutableStateOf(services.loadStartOnLoginPreference()) } + var startOnLoginMessage by remember(services) { mutableStateOf(null) } val platformCapabilities = remember(services, capabilityRefresh, platformCapabilityRefreshRequest) { services.platformCapabilities() } @@ -10288,6 +10377,19 @@ private fun SettingsScreen( } } } + if (services.supportsStartOnLogin) { + item { + SectionTitle("Desktop") + DesktopStartOnLoginSettingsCard( + enabled = startOnLogin, + message = startOnLoginMessage, + onEnabledChanged = { enabled -> + startOnLogin = enabled + startOnLoginMessage = services.saveStartOnLoginPreference(enabled) + }, + ) + } + } item { SectionTitle("Account") Surface( @@ -10558,6 +10660,55 @@ private fun SettingsScreen( } } +@Composable +internal fun DesktopStartOnLoginSettingsCard( + enabled: Boolean, + message: String?, + onEnabledChanged: (Boolean) -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = NextcloudSpacing.Medium), + color = NextcloudTheme.colors.appTile, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Row( + modifier = Modifier.fillMaxWidth().toggleable( + value = enabled, + role = Role.Switch, + onValueChange = onEnabledChanged, + ).padding(NextcloudSpacing.Large), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Large), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface(color = NextcloudTheme.colors.appIconContainer, shape = CircleShape) { + Icon( + NextcloudIcons.Schedule, + contentDescription = null, + modifier = Modifier.padding(12.dp).size(26.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text("Start on login", style = MaterialTheme.typography.titleMedium) + Text( + "Keep folder sync and virtual files available after signing in to this computer.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = NextcloudSpacing.Small), + ) + } + } + Switch(checked = enabled, onCheckedChange = null) + } + } +} + @Composable private fun ProductHeader( title: String, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 413ceda68..76e637484 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -421,6 +421,9 @@ interface NextcloudPlatformServices { /** True only when this platform has durable app-private offline file storage and execution. */ val supportsFileOfflineStorage: Boolean get() = false + /** True when remote files can hydrate into a managed, automatically reclaimable local cache. */ + val supportsVirtualFileStorage: Boolean get() = false + /** * True only for one-way recursive folder availability backed by durable platform execution. * @@ -444,6 +447,15 @@ interface NextcloudPlatformServices { fun saveThemePreference(preference: ThemePreference) + /** Desktop-only login startup integration; unsupported platforms keep this setting hidden. */ + val supportsStartOnLogin: Boolean + get() = false + + fun loadStartOnLoginPreference(): Boolean = false + + /** Returns a user-facing limitation when the preference could not be applied immediately. */ + fun saveStartOnLoginPreference(enabled: Boolean): String? = null + fun loadLastOpenedAppId(): String fun saveLastOpenedAppId(appId: String) @@ -585,6 +597,46 @@ interface NextcloudPlatformServices { "Removing offline copies from this center is not available on this platform.", ) + /** Returns cache and platform-provider status without performing network IO. */ + suspend fun loadVirtualFileStorage( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageSnapshot = defaultVirtualFileStorageSnapshot() + + /** Persists automatic virtual-file cleanup rules and immediately enforces hard limits. */ + suspend fun saveVirtualFileCachePolicy( + session: NextcloudSession, + userId: String, + policy: VirtualFileCachePolicy, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Unsupported( + "Virtual file cache rules are not available on this platform.", + ) + + /** Frees only disposable hydrated content; it must never remove pins or unsynchronized data. */ + suspend fun freeUpVirtualFileSpace( + session: NextcloudSession, + userId: String, + requestedBytes: Long, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Unsupported( + "Virtual file storage cleanup is not available on this platform.", + ) + + /** Activates the operating-system virtual file provider at its configured location. */ + suspend fun activateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Unsupported( + "A system virtual file provider is not available on this platform.", + ) + + /** Stops the operating-system provider without deleting cached or remote content. */ + suspend fun deactivateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = VirtualFileStorageActionResult.Unsupported( + "A system virtual file provider is not available on this platform.", + ) + /** Opens the native folder chooser and persists a least-privilege folder grant. */ suspend fun chooseFileSyncLocalRoot(initialRootHint: String? = null): FileSyncLocalRoot? = null diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt index f277f9562..47d1e2904 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt @@ -9,12 +9,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -730,6 +732,281 @@ internal fun RemoteFolderPickerDialog( ) } +internal fun fileSyncSelectionRelativePath(remoteRootPath: String, absolutePath: String): String? { + val root = canonicalRemoteFolderPath(remoteRootPath) ?: return null + val absolute = canonicalRemoteFolderPath(absolutePath) ?: return null + return when { + root.isEmpty() && absolute.isNotEmpty() -> absolute + root.isNotEmpty() && absolute.startsWith("$root/") -> absolute.removePrefix("$root/") + else -> null + }?.takeIf { relative -> runCatching { requireValidSyncPath(relative) }.isSuccess } +} + +@Composable +internal fun RemoteFileSyncSelectionDialog( + services: NextcloudPlatformServices, + session: NextcloudSession, + userId: String, + remoteRootPath: String, + initialSelection: List, + onDismiss: () -> Unit, + onSelected: (List) -> Unit, + embedded: Boolean = false, +) { + val root = remember(remoteRootPath) { canonicalRemoteFolderPath(remoteRootPath).orEmpty() } + var currentRelativePath by rememberSaveable(root) { mutableStateOf("") } + var selectedPaths by rememberSaveable(root, initialSelection) { + mutableStateOf(initialSelection.distinct().sorted()) + } + var files by remember(session, userId, root) { mutableStateOf?>(null) } + var networkConfirmed by remember(session, userId, root) { mutableStateOf(false) } + var loading by remember(session, userId, root) { mutableStateOf(true) } + var error by remember(session, userId, root) { mutableStateOf(null) } + var loadAttempt by rememberSaveable(session.serverUrl, session.loginName, userId, root) { + mutableStateOf(0) + } + val absoluteCurrentPath = remember(root, currentRelativePath) { + listOf(root, currentRelativePath).filter(String::isNotEmpty).joinToString("/") + } + + LaunchedEffect(session, userId, absoluteCurrentPath, loadAttempt) { + loading = true + files = null + networkConfirmed = false + error = null + runCatching { services.listFilesWithSource(session, userId, absoluteCurrentPath) } + .rethrowRemoteFolderCancellation() + .onSuccess { listing -> + files = listing.files + networkConfirmed = listing.source == NextcloudFileListingSource.Network + if (!networkConfirmed) error = "Connect to Nextcloud to verify selectable items." + } + .onFailure { failure -> + error = failure.message ?: "Could not open this mapped Nextcloud folder." + } + loading = false + } + + val visibleItems = remember(files, absoluteCurrentPath, root) { + files.orEmpty().asSequence() + .filter { file -> + canonicalRemoteFolderPath(file.path) == file.path && + remoteFolderParentPath(file.path) == absoluteCurrentPath + } + .mapNotNull { file -> + fileSyncSelectionRelativePath(root, file.path)?.let { relative -> file to relative } + } + .distinctBy { (_, relative) -> relative } + .sortedWith(compareBy> { !it.first.isDirectory }.thenBy { it.first.name.lowercase() }) + .toList() + } + val breadcrumbs = remember(currentRelativePath) { remoteFolderBreadcrumbs(currentRelativePath) } + + fun toggle(relativePath: String) { + selectedPaths = if (relativePath in selectedPaths) { + selectedPaths - relativePath + } else if (selectedPaths.size < MAX_FILE_SYNC_SELECTION_PATHS) { + (selectedPaths + relativePath).distinct().sorted() + } else { + selectedPaths + } + } + + val pickerText: @Composable () -> Unit = { + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 590.dp), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + item(key = "selection-summary") { + Text( + if (selectedPaths.isEmpty()) { + "Nothing selected yet. Leaving the selection empty syncs the whole mapped folder." + } else if (selectedPaths.size == 1) { + "1 verified item selected" + } else { + "${selectedPaths.size} verified items selected" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (selectedPaths.isNotEmpty()) { + item(key = "selected-items") { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.XSmall)) { + selectedPaths.take(MAX_VISIBLE_SYNC_SELECTIONS).forEach { selected -> + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(NextcloudRadii.Small), + ) { + Row( + modifier = Modifier.padding( + start = NextcloudSpacing.Medium, + end = NextcloudSpacing.XSmall, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + NextcloudIcons.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + selected, + modifier = Modifier.weight(1f).padding(horizontal = NextcloudSpacing.Small), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TextButton(onClick = { toggle(selected) }) { Text("Remove") } + } + } + } + if (selectedPaths.size > MAX_VISIBLE_SYNC_SELECTIONS) { + Text( + "+${selectedPaths.size - MAX_VISIBLE_SYNC_SELECTIONS} more selected", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + item(key = "selection-breadcrumbs") { + Row( + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + ) { + breadcrumbs.forEachIndexed { index, breadcrumb -> + if (index > 0) Text(" / ", color = MaterialTheme.colorScheme.onSurfaceVariant) + if (breadcrumb.path == currentRelativePath) { + Text( + if (index == 0) "Mapped folder" else breadcrumb.label, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + TextButton(onClick = { currentRelativePath = breadcrumb.path }) { + Text( + if (index == 0) "Mapped folder" else breadcrumb.label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + if (currentRelativePath.isNotEmpty() && networkConfirmed) { + item(key = "select-current-folder:$currentRelativePath") { + Row( + modifier = Modifier.fillMaxWidth().clickable { toggle(currentRelativePath) } + .padding(vertical = NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = currentRelativePath in selectedPaths, + onCheckedChange = { toggle(currentRelativePath) }, + ) + Text("Select this folder", fontWeight = FontWeight.SemiBold) + } + } + } + when { + loading -> item(key = "selection-loading") { + Row(Modifier.fillMaxWidth().padding(NextcloudSpacing.Large), Arrangement.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } + } + files == null || !networkConfirmed -> item(key = "selection-error") { + PickerMessage( + message = error ?: "Could not verify this folder.", + parentPath = currentRelativePath.takeIf(String::isNotEmpty)?.substringBeforeLast('/', ""), + onParent = { currentRelativePath = it }, + onRetry = { loadAttempt += 1 }, + ) + } + visibleItems.isEmpty() -> item(key = "selection-empty") { + Text( + "No folders or files are available here.", + modifier = Modifier.padding(NextcloudSpacing.Large), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + else -> items(visibleItems, key = { (_, relative) -> "sync-selection:$relative" }) { (file, relative) -> + Row( + modifier = Modifier.fillMaxWidth() + .clickable { + if (file.isDirectory) currentRelativePath = relative else toggle(relative) + } + .padding(vertical = NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + Checkbox( + checked = relative in selectedPaths, + onCheckedChange = { toggle(relative) }, + ) + Icon( + if (file.isDirectory) NextcloudIcons.Folder else NextcloudIcons.File, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text(file.name, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + if (file.isDirectory) { + Icon(NextcloudIcons.ChevronRight, contentDescription = "Open folder") + } + } + HorizontalDivider() + } + } + } + } + val confirmButton: @Composable () -> Unit = { + Button(enabled = !loading && error == null, onClick = { onSelected(selectedPaths) }) { + Text("Use selection") + } + } + val dismissButton: @Composable () -> Unit = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + if (embedded) { + Surface( + modifier = Modifier.fillMaxWidth().widthIn(max = 720.dp), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(NextcloudRadii.Large), + tonalElevation = 6.dp, + shadowElevation = 12.dp, + ) { + Column( + modifier = Modifier.padding(NextcloudSpacing.XLarge), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + Text("Choose what syncs", style = MaterialTheme.typography.headlineSmall) + pickerText() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + dismissButton() + confirmButton() + } + } + } + } else { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Choose what syncs") }, + text = pickerText, + confirmButton = confirmButton, + dismissButton = dismissButton, + ) + } +} + @Composable private fun PickerMessage( message: String, @@ -760,3 +1037,4 @@ private fun PickerMessage( private const val MAX_REMOTE_FOLDER_PATH_LENGTH = 8_192 private const val MAX_REMOTE_FOLDER_NAME_LENGTH = 255 private const val MAX_REMOTE_FOLDER_SEARCH_LENGTH = 256 +private const val MAX_VISIBLE_SYNC_SELECTIONS = 4 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCache.kt new file mode 100644 index 000000000..0f6653246 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCache.kt @@ -0,0 +1,230 @@ +package dev.obiente.nextcloudnative.app + +/** + * Platform-independent policy and safety model for on-demand file content. + * + * A virtual file remains visible from remote metadata while its content may be absent locally. + * Hydration, pinning, and eviction are intentionally separate from folder synchronization: cached + * content is disposable, pinned content is durable offline data, and dirty synchronized content is + * never a cache eviction candidate. + */ +enum class VirtualFileRetention { + Automatic, + Pinned, +} + +enum class VirtualFileActivity { + Idle, + Hydrating, + Uploading, + Evicting, + NeedsAttention, +} + +data class VirtualFileCachePolicy( + val automaticCleanup: Boolean = true, + val maximumCacheBytes: Long? = DEFAULT_VIRTUAL_FILE_CACHE_BYTES, + val minimumFreeSpaceBytes: Long = DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES, + val unusedFileAgeMillis: Long? = DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS, +) { + init { + require(maximumCacheBytes == null || maximumCacheBytes > 0L) { + "The virtual file cache budget must be positive." + } + require(minimumFreeSpaceBytes >= 0L) { + "The minimum free-space reserve cannot be negative." + } + require(unusedFileAgeMillis == null || unusedFileAgeMillis > 0L) { + "The unused-file age must be positive." + } + } +} + +data class VirtualFileCacheEntry( + val key: FileOfflineKey, + val remoteRevision: String, + val localRevision: String, + val sizeBytes: Long, + val cachedAtEpochMillis: Long, + val lastAccessedAtEpochMillis: Long, + val retention: VirtualFileRetention, + val dirty: Boolean = false, + val activeLeaseCount: Int = 0, + val activity: VirtualFileActivity = VirtualFileActivity.Idle, +) { + init { + require(remoteRevision.isNotBlank()) { "A cached virtual file needs a remote revision." } + require(localRevision.isNotBlank()) { "A cached virtual file needs a local revision." } + require(sizeBytes >= 0L) { "A cached virtual file size cannot be negative." } + require(cachedAtEpochMillis >= 0L) { "A virtual file cache timestamp cannot be negative." } + require(lastAccessedAtEpochMillis >= cachedAtEpochMillis) { + "A virtual file cannot be accessed before its cached generation exists." + } + require(activeLeaseCount >= 0) { "A virtual file lease count cannot be negative." } + } + + val isEvictable: Boolean + get() = retention == VirtualFileRetention.Automatic && + !dirty && + activeLeaseCount == 0 && + activity == VirtualFileActivity.Idle +} + +enum class VirtualFileEvictionReason { + CacheBudget, + MinimumFreeSpace, + UnusedAge, + ManualFreeUp, +} + +data class VirtualFileEviction( + val key: FileOfflineKey, + val expectedLocalRevision: String, + val sizeBytes: Long, + val reasons: Set, +) { + init { + require(expectedLocalRevision.isNotBlank()) + require(sizeBytes >= 0L) + require(reasons.isNotEmpty()) + } +} + +data class VirtualFileEvictionPlan( + val evictions: List, + val cachedBytes: Long, + val reclaimableBytes: Long, + val plannedFreedBytes: Long, + val requiredFreedBytes: Long, + val unmetRequiredBytes: Long, +) { + init { + require(cachedBytes >= 0L) + require(reclaimableBytes in 0L..cachedBytes) + require(plannedFreedBytes in 0L..reclaimableBytes) + require(requiredFreedBytes >= 0L) + require(unmetRequiredBytes >= 0L) + require(evictions.map(VirtualFileEviction::key).distinct().size == evictions.size) + } +} + +/** + * Produces an immutable, revision-guarded eviction plan without touching storage. + * + * Age-expired files are selected first, then least-recently-used generations. Pinned, dirty, + * open, transferring, evicting, and attention-required files are never selected. Executors must + * still compare [VirtualFileEviction.expectedLocalRevision] immediately before deleting bytes. + */ +fun planVirtualFileEviction( + entries: List, + policy: VirtualFileCachePolicy, + availableFreeBytes: Long, + nowEpochMillis: Long, + requestedBytesToFree: Long = 0L, +): VirtualFileEvictionPlan { + require(availableFreeBytes >= 0L) + require(nowEpochMillis >= 0L) + require(requestedBytesToFree >= 0L) + require(entries.map(VirtualFileCacheEntry::key).distinct().size == entries.size) { + "The virtual file cache contains duplicate entries." + } + + val cachedBytes = entries.saturatedSizeSum() + val candidates = entries.filter(VirtualFileCacheEntry::isEvictable) + val reclaimableBytes = candidates.saturatedSizeSum() + val budgetDeficit = if (policy.automaticCleanup) { + policy.maximumCacheBytes?.let { maximum -> (cachedBytes - maximum).coerceAtLeast(0L) } ?: 0L + } else { + 0L + } + val freeSpaceDeficit = if (policy.automaticCleanup) { + (policy.minimumFreeSpaceBytes - availableFreeBytes).coerceAtLeast(0L) + } else { + 0L + } + val requiredBytes = maxOf(budgetDeficit, freeSpaceDeficit, requestedBytesToFree) + val unusedCutoff = if (policy.automaticCleanup) { + policy.unusedFileAgeMillis?.let { age -> (nowEpochMillis - age).coerceAtLeast(0L) } + } else { + null + } + + val ordered = candidates.sortedWith( + compareByDescending { entry -> + unusedCutoff != null && entry.lastAccessedAtEpochMillis <= unusedCutoff + } + .thenBy(VirtualFileCacheEntry::lastAccessedAtEpochMillis) + .thenByDescending(VirtualFileCacheEntry::sizeBytes) + .thenBy(VirtualFileCacheEntry::key), + ) + val selected = mutableListOf() + var plannedBytes = 0L + ordered.forEach { entry -> + val expired = unusedCutoff != null && entry.lastAccessedAtEpochMillis <= unusedCutoff + if (!expired && plannedBytes >= requiredBytes) return@forEach + val reasons = buildSet { + if (expired) add(VirtualFileEvictionReason.UnusedAge) + if (plannedBytes < budgetDeficit) add(VirtualFileEvictionReason.CacheBudget) + if (plannedBytes < freeSpaceDeficit) add(VirtualFileEvictionReason.MinimumFreeSpace) + if (plannedBytes < requestedBytesToFree) add(VirtualFileEvictionReason.ManualFreeUp) + } + if (reasons.isNotEmpty()) { + selected += VirtualFileEviction( + key = entry.key, + expectedLocalRevision = entry.localRevision, + sizeBytes = entry.sizeBytes, + reasons = reasons, + ) + plannedBytes = plannedBytes.saturatedPlus(entry.sizeBytes) + } + } + + return VirtualFileEvictionPlan( + evictions = selected, + cachedBytes = cachedBytes, + reclaimableBytes = reclaimableBytes, + plannedFreedBytes = plannedBytes, + requiredFreedBytes = requiredBytes, + unmetRequiredBytes = (requiredBytes - plannedBytes).coerceAtLeast(0L), + ) +} + +sealed interface VirtualFileOpenPlan { + data class ServeCached(val localRevision: String) : VirtualFileOpenPlan + data class Hydrate(val expectedRemoteRevision: String) : VirtualFileOpenPlan + data object UnavailableOffline : VirtualFileOpenPlan + data class NeedsAttention(val reason: String) : VirtualFileOpenPlan +} + +/** Plans hydrate-on-open without ever serving a known-stale generation as current. */ +fun planVirtualFileOpen( + entry: VirtualFileCacheEntry?, + expectedRemoteRevision: String, + networkAvailable: Boolean, +): VirtualFileOpenPlan { + require(expectedRemoteRevision.isNotBlank()) + if (entry?.activity == VirtualFileActivity.NeedsAttention || entry?.dirty == true) { + return VirtualFileOpenPlan.NeedsAttention( + "This file has local changes or a conflict that must be resolved before hydration.", + ) + } + if (entry != null && entry.remoteRevision == expectedRemoteRevision) { + return VirtualFileOpenPlan.ServeCached(entry.localRevision) + } + return if (networkAvailable) { + VirtualFileOpenPlan.Hydrate(expectedRemoteRevision) + } else { + VirtualFileOpenPlan.UnavailableOffline + } +} + +private fun List.saturatedSizeSum(): Long = fold(0L) { total, entry -> + total.saturatedPlus(entry.sizeBytes) +} + +private fun Long.saturatedPlus(other: Long): Long = + if (Long.MAX_VALUE - this < other) Long.MAX_VALUE else this + other + +const val DEFAULT_VIRTUAL_FILE_CACHE_BYTES = 20L * 1024L * 1024L * 1024L +const val DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES = 10L * 1024L * 1024L * 1024L +const val DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS = 30L * 24L * 60L * 60L * 1_000L diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenter.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenter.kt new file mode 100644 index 000000000..10e50116c --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenter.kt @@ -0,0 +1,120 @@ +package dev.obiente.nextcloudnative.app + +enum class VirtualFileStorageSupport { + Available, + CacheOnly, + Unsupported, +} + +enum class VirtualFilePlatformIntegration { + AndroidDocumentsProvider, + LinuxFilesystemMount, + InAppOnDemandCache, + WindowsCloudFiles, + AppleFileProvider, +} + +enum class VirtualFileProviderState { + Active, + Inactive, + Starting, + NeedsAttention, + NotApplicable, +} + +data class VirtualFileStorageSnapshot( + val support: VirtualFileStorageSupport, + val integration: VirtualFilePlatformIntegration?, + val policy: VirtualFileCachePolicy, + val cachedBytes: Long, + val reclaimableBytes: Long, + val pinnedBytes: Long, + val hydratedFileCount: Int, + val pinnedFileCount: Int, + val availableFreeBytes: Long?, + val storageCapacityBytes: Long?, + val limitations: List = emptyList(), + val providerState: VirtualFileProviderState = VirtualFileProviderState.NotApplicable, + val providerLocation: String? = null, + val pendingWritebackCount: Int = 0, +) { + init { + require(cachedBytes >= 0L) + require(reclaimableBytes in 0L..cachedBytes) + require(pinnedBytes >= 0L) + require(hydratedFileCount >= 0) + require(pinnedFileCount >= 0) + require(availableFreeBytes == null || availableFreeBytes >= 0L) + require(storageCapacityBytes == null || storageCapacityBytes > 0L) + require( + availableFreeBytes == null || storageCapacityBytes == null || + availableFreeBytes <= storageCapacityBytes, + ) + require(support != VirtualFileStorageSupport.Unsupported || integration == null) + require(support != VirtualFileStorageSupport.Unsupported || cachedBytes == 0L) + require(limitations.size <= MAX_VIRTUAL_FILE_LIMITATIONS) + require(limitations.all { it.isNotBlank() && it.length <= MAX_VIRTUAL_FILE_LIMITATION_LENGTH }) + require(providerLocation == null || providerLocation.isNotBlank()) + require(pendingWritebackCount >= 0) + } +} + +sealed interface VirtualFileStorageActionResult { + data class Completed( + val message: String, + val freedBytes: Long = 0L, + ) : VirtualFileStorageActionResult { + init { + require(message.isNotBlank() && message.length <= MAX_VIRTUAL_FILE_ACTION_MESSAGE_LENGTH) + require(freedBytes >= 0L) + } + } + + data class Rejected(val reason: String) : VirtualFileStorageActionResult { + init { + require(reason.isNotBlank() && reason.length <= MAX_VIRTUAL_FILE_ACTION_MESSAGE_LENGTH) + } + } + + data class Unsupported(val reason: String) : VirtualFileStorageActionResult { + init { + require(reason.isNotBlank() && reason.length <= MAX_VIRTUAL_FILE_ACTION_MESSAGE_LENGTH) + } + } +} + +fun defaultVirtualFileStorageSnapshot(): VirtualFileStorageSnapshot = VirtualFileStorageSnapshot( + support = VirtualFileStorageSupport.Unsupported, + integration = null, + policy = VirtualFileCachePolicy(automaticCleanup = false), + cachedBytes = 0L, + reclaimableBytes = 0L, + pinnedBytes = 0L, + hydratedFileCount = 0, + pinnedFileCount = 0, + availableFreeBytes = null, + storageCapacityBytes = null, + limitations = listOf("Virtual file hydration is not available on this platform build."), + providerState = VirtualFileProviderState.NotApplicable, +) + +fun formatVirtualFileBytes(bytes: Long): String { + require(bytes >= 0L) + val units = listOf("B", "KiB", "MiB", "GiB", "TiB") + var value = bytes.toDouble() + var unit = 0 + while (value >= 1024.0 && unit < units.lastIndex) { + value /= 1024.0 + unit += 1 + } + return if (unit == 0) { + "$bytes ${units[unit]}" + } else { + val rounded = (value * 10.0).toLong() / 10.0 + "$rounded ${units[unit]}" + } +} + +private const val MAX_VIRTUAL_FILE_LIMITATIONS = 8 +private const val MAX_VIRTUAL_FILE_LIMITATION_LENGTH = 512 +private const val MAX_VIRTUAL_FILE_ACTION_MESSAGE_LENGTH = 512 diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenterTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenterTest.kt index 0162cf5fb..cc4e1b291 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenterTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenterTest.kt @@ -170,4 +170,42 @@ class FileSyncCenterTest { assertEquals(2, pair.toCenterSummary("Camera").completedCount) } + + @Test + fun `summary exposes why skipped work is paused`() { + val reason = "Directory deletion is paused because selective or ignored items may exist below it." + val pair = FileSyncPair( + id = "pair", + accountId = "account", + localRootId = "content://opaque-grant", + remoteRootPath = "Projects", + configuration = FileSyncConfiguration( + deviceLabel = "phone", + deletionPolicy = FileSyncDeletionPolicy.Propagate, + ignoredPatterns = listOf("**/.cache/**"), + ), + workItems = listOf( + FileSyncWorkItem( + id = 1, + relativePath = "Archive", + observedLocal = null, + observedRemote = RemoteSyncEntry("Archive", SyncEntryKind.Directory, "remote"), + observedBaseline = FileSyncBaseline( + "Archive", + SyncEntryKind.Directory, + "local", + "remote", + ), + operation = FileSyncOperation.Skipped("Archive", reason), + state = FileSyncExecutionState.Skipped, + ), + ), + nextWorkId = 2, + ) + + val summary = pair.toCenterSummary("Projects") + + assertEquals(1, summary.skippedCount) + assertEquals(listOf(reason), summary.skippedReasons) + } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshotTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshotTest.kt index a683ccd86..75f9090c5 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshotTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshotTest.kt @@ -128,6 +128,9 @@ class FileSyncCoordinatorSnapshotTest { deviceLabel = "Test phone", networkPolicy = FileSyncNetworkPolicy.Unmetered, powerPolicy = FileSyncPowerPolicy.Charging, + selectedPaths = listOf("vault.md", "note.md"), + ignoredPatterns = listOf("*.tmp"), + priorityRules = listOf(FileSyncPriorityRule("**/*.raf")), ), baselines = baselines, ) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorTest.kt index abad5ac45..a05e078c6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorTest.kt @@ -160,6 +160,62 @@ class FileSyncCoordinatorTest { assertEquals(emptyList(), state.pair().baselines) } + @Test + fun `selective directory deletion choice remains non destructive`() { + var state = state( + baselines = listOf( + FileSyncBaseline("Photos", SyncEntryKind.Directory, "local-dir", "remote-dir"), + ), + configuration = FileSyncConfiguration( + deviceLabel = "Workstation", + selectedPaths = listOf("Photos/Shared"), + ), + ) + state = scanFileSyncPair( + state, + PAIR_ID, + localEntries = emptyList(), + remoteEntries = listOf(RemoteSyncEntry("Photos", SyncEntryKind.Directory, "remote-dir")), + nowEpochMillis = 10, + ) + val workId = state.pair().workItems.single().id + + state = resolveFileSyncDecision( + state, + PAIR_ID, + workId, + FileSyncDecisionChoice.PropagateDeletion, + ) + + assertIs(state.pair().workItems.single().operation) + assertNull(claimNextFileSyncOperation(state, PAIR_ID, 20).command) + } + + @Test + fun `explicit recovery resets only failures that exhausted automatic retries`() { + var coordinator = scanFileSyncPair( + state(), + PAIR_ID, + localEntries = listOf(local("retry.txt", "local-v1")), + remoteEntries = emptyList(), + nowEpochMillis = 10, + ) + val workId = coordinator.pair().workItems.single().id + repeat(MAX_FILE_SYNC_ATTEMPTS) { attempt -> + coordinator = claimNextFileSyncOperation(coordinator, PAIR_ID, attempt.toLong()).state + coordinator = failFileSyncOperation(coordinator, PAIR_ID, workId, "Temporary failure") + if (attempt + 1 < MAX_FILE_SYNC_ATTEMPTS) { + coordinator = retryFileSyncOperation(coordinator, PAIR_ID, workId) + } + } + + val reset = resetExhaustedFileSyncOperations(coordinator, PAIR_ID).pair().workItems.single() + + assertEquals(FileSyncExecutionState.Ready, reset.state) + assertEquals(0, reset.attemptCount) + assertNull(reset.failureMessage) + } + @Test fun `keep both requires verified convergence for every generated path`() { var state = state(baselines = listOf(baseline("daily.note.md", "l1", "r1"))) @@ -230,6 +286,72 @@ class FileSyncCoordinatorTest { } } + @Test + fun `selective and ignored paths cannot become deletion work`() { + val configuration = FileSyncConfiguration( + deviceLabel = "Test phone", + selectedPaths = listOf("Photos/Keep"), + ignoredPatterns = listOf("*.tmp"), + ) + var state = state( + baselines = listOf( + baseline("Photos/Keep/a.raf", "l1", "r1"), + baseline("Photos/Other/b.raf", "l1", "r1"), + baseline("Photos/Keep/incomplete.tmp", "l1", "r1"), + ), + configuration = configuration, + ) + + state = scanFileSyncPair( + state, + PAIR_ID, + localEntries = listOf(local("Photos/Keep/a.raf", "l2")), + remoteEntries = listOf( + remote("Photos/Keep/a.raf", "r1"), + remote("Photos/Other/b.raf", "r1"), + remote("Photos/Keep/incomplete.tmp", "r1"), + ), + nowEpochMillis = 10, + ) + + assertEquals(listOf("Photos/Keep/a.raf"), state.pair().workItems.map { it.relativePath }) + assertEquals( + setOf("Photos/Keep/a.raf", "Photos/Other/b.raf", "Photos/Keep/incomplete.tmp"), + state.pair().baselines.mapTo(linkedSetOf(), FileSyncBaseline::relativePath), + ) + } + + @Test + fun `directories are created first then raw files outrank jpeg files across folders`() { + val configuration = FileSyncConfiguration( + deviceLabel = "Test phone", + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + ), + ) + var state = state(configuration = configuration) + + state = scanFileSyncPair( + state, + PAIR_ID, + localEntries = listOf( + LocalSyncEntry("Shoot", SyncEntryKind.Directory, "dir"), + LocalSyncEntry("Other", SyncEntryKind.Directory, "other-dir"), + local("Shoot/export.jpg", "jpg"), + local("Other/negative.raf", "raf"), + local("Shoot/sidecar.xmp", "xmp"), + ), + remoteEntries = emptyList(), + nowEpochMillis = 10, + ) + + assertEquals( + listOf("Other", "Shoot", "Other/negative.raf", "Shoot/export.jpg", "Shoot/sidecar.xmp"), + state.pair().workItems.map(FileSyncWorkItem::relativePath), + ) + } + @Test fun `pair and failure fields are bounded before persistence`() { assertFailsWith { @@ -261,14 +383,17 @@ class FileSyncCoordinatorTest { } } - private fun state(baselines: List = emptyList()) = FileSyncCoordinatorState( + private fun state( + baselines: List = emptyList(), + configuration: FileSyncConfiguration = FileSyncConfiguration(deviceLabel = "Test phone"), + ) = FileSyncCoordinatorState( pairs = listOf( FileSyncPair( id = PAIR_ID, accountId = "account-a", localRootId = "android-tree:primary-notes", remoteRootPath = "Notes", - configuration = FileSyncConfiguration(deviceLabel = "Test phone"), + configuration = configuration, baselines = baselines, ), ), diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncDirectionPresentationTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncDirectionPresentationTest.kt index b5036ed48..0f3e0059e 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncDirectionPresentationTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncDirectionPresentationTest.kt @@ -7,15 +7,15 @@ class FileSyncDirectionPresentationTest { @Test fun `sync route marker follows configured direction`() { assertEquals( - "Device ↔ Nextcloud /Notes", + "Device <-> Nextcloud /Notes", fileSyncRouteLabel(FileSyncDirection.Bidirectional, "Notes"), ) assertEquals( - "Nextcloud /Documents → device", + "Nextcloud /Documents -> device", fileSyncRouteLabel(FileSyncDirection.DownloadOnly, "/Documents"), ) assertEquals( - "Device → Nextcloud /Photos/Camera", + "Device -> Nextcloud /Photos/Camera", fileSyncRouteLabel(FileSyncDirection.UploadOnly, "Photos/Camera"), ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanningTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanningTest.kt index 97e78fc4c..e41bb834c 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanningTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanningTest.kt @@ -124,6 +124,68 @@ class FileSyncPlanningTest { assertEquals("remote-1", assertIs(propagate).expectedRemoteEtag) } + @Test + fun oneWaySyncNeverMutatesTheProhibitedSideDuringDeletionEditRaces() { + val uploadOnly = planFileSync( + localEntries = emptyList(), + remoteEntries = listOf(remote("vault/a.md", "remote-2")), + baselines = listOf(baseline("vault/a.md", "local-1", "remote-1")), + configuration = config.copy(direction = FileSyncDirection.UploadOnly), + ).operations.single() + assertEquals( + FileSyncDecisionReason.LocalDeletion, + assertIs(uploadOnly).reason, + ) + + val downloadOnly = planFileSync( + localEntries = listOf(local("vault/a.md", "local-2")), + remoteEntries = emptyList(), + baselines = listOf(baseline("vault/a.md", "local-1", "remote-1")), + configuration = config.copy(direction = FileSyncDirection.DownloadOnly), + ).operations.single() + assertEquals( + FileSyncDecisionReason.RemoteDeletion, + assertIs(downloadOnly).reason, + ) + + fun scanned(configuration: FileSyncConfiguration, localEntries: List, remoteEntries: List) = + scanFileSyncPair( + state = FileSyncCoordinatorState( + listOf( + FileSyncPair( + id = "pair", + accountId = "account", + localRootId = "root", + remoteRootPath = "vault", + configuration = configuration, + baselines = listOf(baseline("vault/a.md", "local-1", "remote-1")), + ), + ), + ), + pairId = "pair", + localEntries = localEntries, + remoteEntries = remoteEntries, + nowEpochMillis = 1L, + ).pairs.single().workItems.single().decision?.choices + + assertEquals( + setOf(FileSyncDecisionChoice.PropagateDeletion, FileSyncDecisionChoice.Skip), + scanned( + config.copy(direction = FileSyncDirection.UploadOnly), + emptyList(), + listOf(remote("vault/a.md", "remote-2")), + ), + ) + assertEquals( + setOf(FileSyncDecisionChoice.PropagateDeletion, FileSyncDecisionChoice.Skip), + scanned( + config.copy(direction = FileSyncDirection.DownloadOnly), + listOf(local("vault/a.md", "local-2")), + emptyList(), + ), + ) + } + @Test fun directoryDeletionsUseTheSameExplicitPolicyAsFiles() { val operation = planFileSync( @@ -140,6 +202,44 @@ class FileSyncPlanningTest { assertEquals("remote-dir", assertIs(operation).expectedRemoteEtag) } + @Test + fun selectiveSyncNeverRecursivelyDeletesAnOnlyPartiallyVisibleRemoteDirectory() { + val operation = planFileSync( + localEntries = emptyList(), + remoteEntries = listOf( + RemoteSyncEntry("Photos", SyncEntryKind.Directory, "remote-dir"), + ), + baselines = listOf( + FileSyncBaseline("Photos", SyncEntryKind.Directory, "local-dir", "remote-dir"), + ), + configuration = config.copy( + deletionPolicy = FileSyncDeletionPolicy.Propagate, + selectedPaths = listOf("Photos/Shared"), + ), + ).operations.single() + + assertIs(operation) + } + + @Test + fun ignoredItemsPreventRecursiveLocalDirectoryDeletion() { + val operation = planFileSync( + localEntries = listOf( + LocalSyncEntry("Projects", SyncEntryKind.Directory, "local-dir"), + ), + remoteEntries = emptyList(), + baselines = listOf( + FileSyncBaseline("Projects", SyncEntryKind.Directory, "local-dir", "remote-dir"), + ), + configuration = config.copy( + deletionPolicy = FileSyncDeletionPolicy.Propagate, + ignoredPatterns = listOf("**/.cache/**"), + ), + ).operations.single() + + assertIs(operation) + } + @Test fun unsafeOrDuplicatePathsAreRejectedBeforePlanning() { assertFailsWith { local("../secret", "1") } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPolicyTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPolicyTest.kt new file mode 100644 index 000000000..5ab394544 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileSyncPolicyTest.kt @@ -0,0 +1,58 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FileSyncPolicyTest { + @Test + fun `portable globs match names and paths case insensitively`() { + assertTrue(fileSyncGlobMatches("*.raf", "Shoot/DSC_1001.RAF")) + assertTrue(fileSyncGlobMatches("**/*.jp?g", "Shoot/Exports/preview.JPEG")) + assertTrue(fileSyncGlobMatches("cache/**", "cache")) + assertTrue(fileSyncGlobMatches("cache/**", "cache/previews/a.jpg")) + assertFalse(fileSyncGlobMatches("Shoot/*.raf", "Shoot/Day-1/a.raf")) + assertFalse(fileSyncGlobMatches("*.raf", "Shoot/a.jpg")) + } + + @Test + fun `selective roots retain ancestors and descendants but ignore rules win`() { + val configuration = FileSyncConfiguration( + deviceLabel = "Workstation", + selectedPaths = listOf("Photos/2026/July", "Documents/report.odt"), + ignoredPatterns = listOf("**/.thumbnails/**", "*.part"), + ) + + assertTrue(configuration.includesSyncPath("Photos", SyncEntryKind.Directory)) + assertTrue(configuration.includesSyncPath("Photos/2026", SyncEntryKind.Directory)) + assertTrue(configuration.includesSyncPath("Photos/2026/July", SyncEntryKind.Directory)) + assertTrue(configuration.includesSyncPath("Photos/2026/July/a.raf", SyncEntryKind.File)) + assertTrue(configuration.includesSyncPath("Documents/report.odt", SyncEntryKind.File)) + assertFalse(configuration.includesSyncPath("Photos/2025", SyncEntryKind.Directory)) + assertFalse(configuration.includesSyncPath("Photos/2026/July/a.part", SyncEntryKind.File)) + assertFalse( + configuration.includesSyncPath( + "Photos/2026/July/.thumbnails/a.jpg", + SyncEntryKind.File, + ), + ) + } + + @Test + fun `first matching priority rule wins and unmatched files follow`() { + val configuration = FileSyncConfiguration( + deviceLabel = "Workstation", + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + FileSyncPriorityRule("**/*.jpeg"), + ), + ) + + assertEquals(0, configuration.fileSyncPriority("Shoot/a.RAF")) + assertEquals(1, configuration.fileSyncPriority("Shoot/a.jpg")) + assertEquals(2, configuration.fileSyncPriority("Shoot/a.jpeg")) + assertEquals(3, configuration.fileSyncPriority("Shoot/a.xmp")) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerTest.kt index ee1e8e52f..b0c738285 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerTest.kt @@ -9,6 +9,18 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class RemoteFolderPickerTest { + @Test + fun `selective sync identities are resolved relative to the mapped remote root`() { + assertEquals( + "Camera/2026/photo.raf", + fileSyncSelectionRelativePath("Photos", "Photos/Camera/2026/photo.raf"), + ) + assertEquals("Documents/report.pdf", fileSyncSelectionRelativePath("", "Documents/report.pdf")) + assertNull(fileSyncSelectionRelativePath("Photos", "Documents/report.pdf")) + assertNull(fileSyncSelectionRelativePath("Photos", "Photos")) + assertNull(fileSyncSelectionRelativePath("Photos", "Photos/../Secrets")) + } + @Test fun `canonical paths preserve server names while manual input normalizes outer separators`() { assertEquals("", canonicalRemoteFolderPath("")) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCacheTest.kt new file mode 100644 index 000000000..2bce7d774 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCacheTest.kt @@ -0,0 +1,141 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class VirtualFileCacheTest { + @Test + fun `automatic cleanup expires old files and then uses least recently used order`() { + val entries = listOf( + entry("old.raf", size = 40L, accessed = 10L), + entry("recent.jpg", size = 30L, accessed = 900L), + entry("older.jpg", size = 50L, accessed = 500L), + ) + val plan = planVirtualFileEviction( + entries = entries, + policy = VirtualFileCachePolicy( + maximumCacheBytes = 60L, + minimumFreeSpaceBytes = 0L, + unusedFileAgeMillis = 800L, + ), + availableFreeBytes = 1_000L, + nowEpochMillis = 1_000L, + ) + + assertEquals(listOf("old.raf", "older.jpg"), plan.evictions.map { it.key.relativePath }) + assertEquals(90L, plan.plannedFreedBytes) + assertEquals(60L, plan.requiredFreedBytes) + assertEquals(0L, plan.unmetRequiredBytes) + assertTrue(VirtualFileEvictionReason.UnusedAge in plan.evictions.first().reasons) + assertTrue(VirtualFileEvictionReason.CacheBudget in plan.evictions.first().reasons) + } + + @Test + fun `pinned dirty open and active files are never evicted`() { + val protected = listOf( + entry("pinned.raf", retention = VirtualFileRetention.Pinned), + entry("dirty.xmp", dirty = true), + entry("open.mov", leaseCount = 1), + entry("uploading.jpg", activity = VirtualFileActivity.Uploading), + entry("conflict.md", activity = VirtualFileActivity.NeedsAttention), + ) + val disposable = entry("preview.jpg", size = 20L) + val plan = planVirtualFileEviction( + entries = protected + disposable, + policy = VirtualFileCachePolicy( + maximumCacheBytes = 1L, + minimumFreeSpaceBytes = 100L, + unusedFileAgeMillis = null, + ), + availableFreeBytes = 0L, + nowEpochMillis = 1_000L, + requestedBytesToFree = 1_000L, + ) + + assertEquals(listOf("preview.jpg"), plan.evictions.map { it.key.relativePath }) + assertEquals(20L, plan.reclaimableBytes) + assertEquals(980L, plan.unmetRequiredBytes) + } + + @Test + fun `manual free up works while automatic cleanup is disabled`() { + val plan = planVirtualFileEviction( + entries = listOf( + entry("a.jpg", size = 25L, accessed = 20L), + entry("b.jpg", size = 30L, accessed = 10L), + ), + policy = VirtualFileCachePolicy( + automaticCleanup = false, + maximumCacheBytes = 1L, + minimumFreeSpaceBytes = 1_000L, + unusedFileAgeMillis = 1L, + ), + availableFreeBytes = 0L, + nowEpochMillis = 1_000L, + requestedBytesToFree = 26L, + ) + + assertEquals(listOf("b.jpg"), plan.evictions.map { it.key.relativePath }) + assertEquals(setOf(VirtualFileEvictionReason.ManualFreeUp), plan.evictions.single().reasons) + } + + @Test + fun `open uses exact cached revision and otherwise hydrates or reports offline`() { + val cached = entry("photo.raf", remote = "etag-1", local = "sha256:one") + assertEquals( + VirtualFileOpenPlan.ServeCached("sha256:one"), + planVirtualFileOpen(cached, expectedRemoteRevision = "etag-1", networkAvailable = false), + ) + assertEquals( + VirtualFileOpenPlan.Hydrate("etag-2"), + planVirtualFileOpen(cached, expectedRemoteRevision = "etag-2", networkAvailable = true), + ) + assertIs( + planVirtualFileOpen(cached, expectedRemoteRevision = "etag-2", networkAvailable = false), + ) + assertIs( + planVirtualFileOpen(cached.copy(dirty = true), "etag-1", networkAvailable = true), + ) + } + + @Test + fun `invalid policies and duplicate entries fail closed`() { + assertFailsWith { VirtualFileCachePolicy(maximumCacheBytes = 0L) } + assertFailsWith { VirtualFileCachePolicy(minimumFreeSpaceBytes = -1L) } + val duplicate = entry("same.jpg") + assertFailsWith { + planVirtualFileEviction( + entries = listOf(duplicate, duplicate), + policy = VirtualFileCachePolicy(), + availableFreeBytes = 1L, + nowEpochMillis = 1L, + ) + } + } + + private fun entry( + path: String, + size: Long = 10L, + accessed: Long = 100L, + remote: String = "etag-$path", + local: String = "sha256:$path", + retention: VirtualFileRetention = VirtualFileRetention.Automatic, + dirty: Boolean = false, + leaseCount: Int = 0, + activity: VirtualFileActivity = VirtualFileActivity.Idle, + ) = VirtualFileCacheEntry( + key = FileOfflineKey("account-a", path), + remoteRevision = remote, + localRevision = local, + sizeBytes = size, + cachedAtEpochMillis = 0L, + lastAccessedAtEpochMillis = accessed, + retention = retention, + dirty = dirty, + activeLeaseCount = leaseCount, + activity = activity, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenterTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenterTest.kt new file mode 100644 index 000000000..51c2f1808 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenterTest.kt @@ -0,0 +1,40 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class VirtualFileStorageCenterTest { + @Test + fun `unsupported default cannot imply cached content or an integration`() { + val snapshot = defaultVirtualFileStorageSnapshot() + assertEquals(VirtualFileStorageSupport.Unsupported, snapshot.support) + assertEquals(null, snapshot.integration) + assertEquals(0L, snapshot.cachedBytes) + } + + @Test + fun `snapshot rejects impossible storage and reclaimable values`() { + val base = VirtualFileStorageSnapshot( + support = VirtualFileStorageSupport.Available, + integration = VirtualFilePlatformIntegration.AndroidDocumentsProvider, + policy = VirtualFileCachePolicy(), + cachedBytes = 100L, + reclaimableBytes = 80L, + pinnedBytes = 20L, + hydratedFileCount = 2, + pinnedFileCount = 1, + availableFreeBytes = 900L, + storageCapacityBytes = 1_000L, + ) + assertEquals(80L, base.reclaimableBytes) + assertFailsWith { base.copy(reclaimableBytes = 101L) } + assertFailsWith { base.copy(availableFreeBytes = 1_001L) } + assertFailsWith { + base.copy( + support = VirtualFileStorageSupport.Unsupported, + integration = VirtualFilePlatformIntegration.AndroidDocumentsProvider, + ) + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt index 034e52f9c..c877c92d7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt @@ -6,6 +6,7 @@ import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.security.MessageDigest +import java.util.prefs.Preferences import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -15,6 +16,14 @@ internal data class DesktopCachedFileContent( val etag: String, ) +internal data class DesktopVirtualFileCacheSummary( + val policy: VirtualFileCachePolicy, + val cachedBytes: Long, + val reclaimableBytes: Long, + val entryCount: Int, + val availableFreeBytes: Long, +) + /** * Disposable, account-private Files read cache for desktop. * @@ -27,6 +36,8 @@ internal class DesktopFileReadCache( private val maximumContentBytes: Long = DEFAULT_MAXIMUM_CONTENT_BYTES, private val maximumEntryBytes: Long = DEFAULT_MAXIMUM_ENTRY_BYTES, ) { + private val preferences = Preferences.userRoot() + .node("dev/obiente/nextcloudnative/virtual-file-cache") init { require(maximumContentBytes > 0L) require(maximumEntryBytes in 1L..maximumContentBytes) @@ -88,6 +99,19 @@ internal class DesktopFileReadCache( val bytes = blob.readBytes() if (bytes.size.toLong() != record.size) return null if (sha256Hex(bytes) != record.sha256) return null + val current = load(accountId) + save( + accountId, + current.copy( + content = current.content.map { cached -> + if (cached.path == normalized) { + cached.copy(lastAccessedAtEpochMillis = System.currentTimeMillis()) + } else { + cached + } + }, + ), + ) return DesktopCachedFileContent(bytes, record.mimeType, record.etag) } @@ -125,12 +149,78 @@ internal class DesktopFileReadCache( blobName = blobName, sha256 = sha256Hex(content.bytes), storedAtEpochMillis = nowEpochMillis, + lastAccessedAtEpochMillis = nowEpochMillis, ), ).bounded() save(accountId, index) - return index.content.any { cached -> cached.path == normalized && cached.blobName == blobName } + applyEviction(accountId, requestedBytesToFree = 0L, nowEpochMillis = nowEpochMillis) + return load(accountId).content.any { cached -> + cached.path == normalized && cached.blobName == blobName + } + } + + @Synchronized + fun loadPolicy(): VirtualFileCachePolicy = VirtualFileCachePolicy( + automaticCleanup = preferences.getBoolean(KEY_AUTOMATIC_CLEANUP, true), + maximumCacheBytes = preferences.getLong( + KEY_MAXIMUM_CACHE_BYTES, + DEFAULT_VIRTUAL_FILE_CACHE_BYTES, + ).optionalPositiveOrDefault(DEFAULT_VIRTUAL_FILE_CACHE_BYTES), + minimumFreeSpaceBytes = preferences.getLong( + KEY_MINIMUM_FREE_BYTES, + DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES, + ).coerceAtLeast(0L), + unusedFileAgeMillis = preferences.getLong( + KEY_UNUSED_FILE_AGE, + DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS, + ).optionalPositiveOrDefault(DEFAULT_VIRTUAL_FILE_UNUSED_AGE_MILLIS), + ) + + private fun Long.optionalPositiveOrDefault(defaultValue: Long): Long? = when { + this == UNLIMITED_SENTINEL -> null + this > 0L -> this + else -> defaultValue + } + + @Synchronized + fun savePolicy(policy: VirtualFileCachePolicy) { + preferences.putBoolean(KEY_AUTOMATIC_CLEANUP, policy.automaticCleanup) + preferences.putLong(KEY_MAXIMUM_CACHE_BYTES, policy.maximumCacheBytes ?: UNLIMITED_SENTINEL) + preferences.putLong(KEY_MINIMUM_FREE_BYTES, policy.minimumFreeSpaceBytes) + preferences.putLong(KEY_UNUSED_FILE_AGE, policy.unusedFileAgeMillis ?: UNLIMITED_SENTINEL) + root.listFiles().orEmpty() + .filter { it.isDirectory && it.name.isSha256Hex() } + .forEach { applyEviction(it.name, requestedBytesToFree = 0L) } + } + + @Synchronized + fun virtualFileSummary( + accountId: String, + nowEpochMillis: Long = System.currentTimeMillis(), + ): DesktopVirtualFileCacheSummary { + val entries = load(accountId).content.toVirtualFileEntries(accountId) + val plan = planVirtualFileEviction( + entries = entries, + policy = loadPolicy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = nowEpochMillis, + ) + return DesktopVirtualFileCacheSummary( + policy = loadPolicy(), + cachedBytes = plan.cachedBytes, + reclaimableBytes = plan.reclaimableBytes, + entryCount = entries.size, + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + ) } + @Synchronized + fun freeUpVirtualFiles( + accountId: String, + requestedBytesToFree: Long, + nowEpochMillis: Long = System.currentTimeMillis(), + ): VirtualFileEvictionPlan = applyEviction(accountId, requestedBytesToFree, nowEpochMillis) + @Synchronized fun invalidate(accountId: String, path: String) { val normalized = path.cachePath() @@ -162,11 +252,17 @@ internal class DesktopFileReadCache( require(boundedListings.sumOf { it.files.size } <= MAX_TOTAL_METADATA_ENTRIES) { "The Files metadata cache exceeds its entry limit." } + val policyBudget = if (loadPolicy().automaticCleanup) { + loadPolicy().maximumCacheBytes ?: maximumContentBytes + } else { + maximumContentBytes + } + val effectiveMaximum = minOf(maximumContentBytes, policyBudget) var retainedBytes = 0L val retainedContent = content - .sortedByDescending(CachedContentV1::storedAtEpochMillis) + .sortedByDescending(CachedContentV1::lastAccessedAtEpochMillis) .filter { entry -> - if (retainedBytes + entry.size > maximumContentBytes) { + if (retainedBytes + entry.size > effectiveMaximum) { false } else { retainedBytes += entry.size @@ -201,6 +297,47 @@ internal class DesktopFileReadCache( .forEach(File::delete) } + private fun applyEviction( + accountId: String, + requestedBytesToFree: Long, + nowEpochMillis: Long = System.currentTimeMillis(), + ): VirtualFileEvictionPlan { + require(requestedBytesToFree >= 0L) + val current = load(accountId) + val plan = planVirtualFileEviction( + entries = current.content.toVirtualFileEntries(accountId), + policy = loadPolicy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = nowEpochMillis, + requestedBytesToFree = requestedBytesToFree, + ) + val byPath = current.content.associateBy(CachedContentV1::path) + val removed = plan.evictions.mapNotNullTo(mutableSetOf()) { eviction -> + val record = byPath[eviction.key.relativePath] ?: return@mapNotNullTo null + if ("sha256:${record.sha256}" != eviction.expectedLocalRevision) return@mapNotNullTo null + val blob = File(accountDirectory(accountId), record.blobName) + if (!blob.exists() || blob.delete()) record.path else null + } + if (removed.isNotEmpty()) { + save(accountId, current.copy(content = current.content.filterNot { it.path in removed })) + } + return plan + } + + private fun List.toVirtualFileEntries(accountId: String): List = + map { record -> + VirtualFileCacheEntry( + key = FileOfflineKey(accountId, record.path), + remoteRevision = record.etag, + localRevision = "sha256:${record.sha256}", + sizeBytes = record.size, + cachedAtEpochMillis = record.storedAtEpochMillis, + lastAccessedAtEpochMillis = record.lastAccessedAtEpochMillis, + retention = VirtualFileRetention.Automatic, + activity = VirtualFileActivity.Idle, + ) + } + private fun accountDirectory(accountId: String): File { require(accountId.isSha256Hex()) return File(root, accountId) @@ -233,6 +370,7 @@ internal class DesktopFileReadCache( require(content.blobName.removeSuffix(".blob").isSha256Hex()) require(content.sha256.isSha256Hex()) require(content.storedAtEpochMillis >= 0L) + require(content.lastAccessedAtEpochMillis >= content.storedAtEpochMillis) } } @@ -270,8 +408,13 @@ internal class DesktopFileReadCache( const val MAX_FILE_NAME_LENGTH = 1_024 const val MAX_ETAG_LENGTH = 4_096 const val MAX_MIME_TYPE_LENGTH = 512 - const val DEFAULT_MAXIMUM_ENTRY_BYTES = 16L * 1024L * 1024L - const val DEFAULT_MAXIMUM_CONTENT_BYTES = 128L * 1024L * 1024L + const val DEFAULT_MAXIMUM_ENTRY_BYTES = 512L * 1024L * 1024L + const val DEFAULT_MAXIMUM_CONTENT_BYTES = 256L * 1024L * 1024L * 1024L + const val KEY_AUTOMATIC_CLEANUP = "automatic-cleanup" + const val KEY_MAXIMUM_CACHE_BYTES = "maximum-cache-bytes" + const val KEY_MINIMUM_FREE_BYTES = "minimum-free-bytes" + const val KEY_UNUSED_FILE_AGE = "unused-file-age" + const val UNLIMITED_SENTINEL = -1L } } @@ -335,6 +478,7 @@ private data class CachedContentV1( val blobName: String, val sha256: String, val storedAtEpochMillis: Long, + val lastAccessedAtEpochMillis: Long = storedAtEpochMillis, ) private val cacheJson = Json { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt new file mode 100644 index 000000000..2df920b17 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt @@ -0,0 +1,764 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import javax.swing.JFileChooser +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Durable manual desktop executor. The common coordinator owns all planning and conflict rules. */ +internal class DesktopFileSyncEngine( + private val store: DesktopFileSyncStore = DesktopFileSyncStore(), + private val stagingRoot: File = desktopFileSyncStagingDirectory(), + private val minimumFreeSpaceBytes: () -> Long = { 0L }, +) { + private val selectedRoots = ConcurrentHashMap() + private val lock = Mutex() + + suspend fun chooseLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = withContext(Dispatchers.IO) { + val chooser = JFileChooser().apply { + dialogTitle = "Choose a folder to sync" + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false + initialRootHint?.let { hint -> + selectedRoots[hint]?.takeIf(File::isDirectory)?.let { currentDirectory = it } + } + } + if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) return@withContext null + val selected = chooser.selectedFile.toPath().toAbsolutePath().normalize() + require(Files.isDirectory(selected, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(selected)) { + "Choose a regular local folder, not a symbolic link." + } + val token = "desktop-selection:${UUID.randomUUID()}" + selectedRoots[token] = selected.toFile() + FileSyncLocalRoot(token, selected.fileName?.toString()?.takeIf(String::isNotBlank) ?: "Selected folder") + } + + suspend fun loadCenter(session: NextcloudSession): FileSyncCenterSnapshot = lock.withLock { + store.withExclusiveAccess { + val accountId = desktopFileCacheAccountId(session) + val state = store.load() + FileSyncCenterSnapshot( + support = FileSyncCenterSupport.Available, + pairs = state.coordinator.pairs.filter { it.accountId == accountId }.map { pair -> + val root = state.roots.firstOrNull { it.id == pair.localRootId } + pair.toCenterSummary( + localDisplayName = root?.displayName ?: "Selected folder", + localRootPath = root?.absolutePath, + scheduleDescription = "Automatic sync while Nextcloud Native is running", + ) + }, + limitation = null, + ) + } + } + + suspend fun loadTrayActivities( + session: NextcloudSession, + limit: Int = MAX_TRAY_ACTIVITY_ITEMS, + ): List = lock.withLock { + store.withExclusiveAccess { + require(limit in 1..MAX_TRAY_ACTIVITY_ITEMS) + val accountId = desktopFileCacheAccountId(session) + val state = store.load() + state.coordinator.pairs + .asSequence() + .filter { it.accountId == accountId } + .flatMap { pair -> + val root = state.roots.firstOrNull { it.id == pair.localRootId } + val pairLabel = syncPairLabel(root?.displayName ?: "Selected folder", pair.remoteRootPath) + pair.workItems.asSequence() + .filter { it.state != FileSyncExecutionState.Skipped } + .map { work -> + DesktopFileSyncTrayActivity( + stableId = "${pair.id}:${work.id}", + relativePath = work.relativePath, + pairLabel = pairLabel, + phase = when (work.state) { + FileSyncExecutionState.AwaitingDecision -> + DesktopFileSyncTrayActivityPhase.Conflict + FileSyncExecutionState.Failed -> DesktopFileSyncTrayActivityPhase.Failed + FileSyncExecutionState.Ready -> DesktopFileSyncTrayActivityPhase.Waiting + FileSyncExecutionState.Running -> work.operation.toTrayActivityPhase() + FileSyncExecutionState.Skipped -> DesktopFileSyncTrayActivityPhase.Waiting + }, + sizeBytes = work.observedLocal?.size ?: work.observedRemote?.size, + detail = work.failureMessage, + ) + } + } + .sortedWith( + compareBy { + when (it.phase) { + DesktopFileSyncTrayActivityPhase.Uploading, + DesktopFileSyncTrayActivityPhase.Downloading, + DesktopFileSyncTrayActivityPhase.Preparing, + -> 0 + DesktopFileSyncTrayActivityPhase.Conflict, + DesktopFileSyncTrayActivityPhase.Failed, + -> 1 + DesktopFileSyncTrayActivityPhase.Waiting -> 2 + DesktopFileSyncTrayActivityPhase.Completed -> 3 + } + }.thenBy(DesktopFileSyncTrayActivity::relativePath), + ) + .take(limit) + .toList() + } + } + + suspend fun addPair( + session: NextcloudSession, + localRoot: FileSyncLocalRoot, + remoteRootPath: String, + configuration: FileSyncConfiguration, + ): FileSyncCenterActionResult = lock.withLock { + store.withExclusiveAccess transaction@ { + val selected = selectedRoots[localRoot.localRootId] + ?: return@transaction FileSyncCenterActionResult.Rejected("Choose the local folder again.") + val canonical = selected.canonicalFile + DesktopFileSyncLocalTree(canonical) + val normalizedRemote = normalizeRemoteRoot(remoteRootPath) + val accountId = desktopFileCacheAccountId(session) + val current = store.load() + if (current.coordinator.pairs.any { pair -> + val existingRoot = current.roots.firstOrNull { it.id == pair.localRootId } ?: return@any true + desktopSyncMappingsOverlap( + existingAccountId = pair.accountId, + requestedAccountId = accountId, + existingLocalRoot = existingRoot.absolutePath, + requestedLocalRoot = canonical.absolutePath, + existingRemoteRoot = pair.remoteRootPath, + requestedRemoteRoot = normalizedRemote, + ) + }) { + return@transaction FileSyncCenterActionResult.Rejected( + "This folder overlaps another local or Nextcloud sync mapping. Choose separate roots.", + ) + } + val rootId = UUID.randomUUID().toString() + val pair = FileSyncPair( + id = UUID.randomUUID().toString(), + accountId = accountId, + localRootId = rootId, + remoteRootPath = normalizedRemote, + configuration = configuration, + ) + store.save( + current.copy( + coordinator = addFileSyncPair(current.coordinator, pair), + roots = current.roots + DesktopFileSyncRootRecord( + rootId, + canonical.absolutePath, + localRoot.displayName, + ), + ), + ) + selectedRoots.remove(localRoot.localRootId) + FileSyncCenterActionResult.Completed("Folder sync pair added. Run it to review the first sync.") + } + } + + suspend fun removePair(session: NextcloudSession, pairId: String): FileSyncCenterActionResult = lock.withLock { + store.withExclusiveAccess transaction@ { + val current = store.load() + val pair = current.coordinator.pairs.firstOrNull { it.id == pairId } + ?: return@transaction FileSyncCenterActionResult.Rejected("The folder sync pair no longer exists.") + if (pair.accountId != desktopFileCacheAccountId(session)) { + return@transaction FileSyncCenterActionResult.Rejected( + "This folder sync pair belongs to another account.", + ) + } + val remaining = removeFileSyncPair(current.coordinator, pairId) + store.save( + current.copy( + coordinator = remaining, + roots = current.roots.filterNot { root -> + root.id == pair.localRootId && remaining.pairs.none { it.localRootId == root.id } + }, + ), + ) + FileSyncCenterActionResult.Completed("Folder sync pair removed. No local or server files were deleted.") + } + } + + suspend fun runPair( + session: NextcloudSession, + userId: String, + pairId: String, + onProgress: (DesktopFileSyncProgressEvent) -> Unit = {}, + shouldContinue: () -> Boolean = { true }, + resetExhaustedFailures: Boolean = false, + ): FileSyncCenterActionResult = lock.withLock { + store.withExclusiveAccess { + runPairLocked(session, userId, pairId, onProgress, shouldContinue, resetExhaustedFailures) + } + } + + suspend fun resolveConflictAndRun( + session: NextcloudSession, + userId: String, + pairId: String, + workId: Long, + choice: FileSyncDecisionChoice, + onProgress: (DesktopFileSyncProgressEvent) -> Unit = {}, + shouldContinue: () -> Boolean = { true }, + ): FileSyncCenterActionResult = lock.withLock { + store.withExclusiveAccess transaction@ { + val current = store.load() + val pair = current.coordinator.pairs.firstOrNull { it.id == pairId } + ?: return@transaction FileSyncCenterActionResult.Rejected("The folder sync pair no longer exists.") + if (pair.accountId != desktopFileCacheAccountId(session)) { + return@transaction FileSyncCenterActionResult.Rejected( + "This folder sync pair belongs to another account.", + ) + } + val resolved = runCatching { + resolveFileSyncDecision(current.coordinator, pairId, workId, choice) + }.getOrElse { failure -> + return@transaction FileSyncCenterActionResult.Rejected( + safeFailureMessage(failure, "That conflict decision is no longer valid. Scan again."), + ) + } + store.save(current.copy(coordinator = resolved)) + runPairLocked( + session, + userId, + pairId, + onProgress, + shouldContinue, + resetExhaustedFailures = true, + ) + } + } + + private fun runPairLocked( + session: NextcloudSession, + userId: String, + pairId: String, + onProgress: (DesktopFileSyncProgressEvent) -> Unit, + shouldContinue: () -> Boolean, + resetExhaustedFailures: Boolean, + ): FileSyncCenterActionResult { + reclaimDesktopFileSyncStages(stagingRoot) + var persisted = store.load() + val initialPair = persisted.coordinator.pairs.firstOrNull { it.id == pairId } + ?: return FileSyncCenterActionResult.Rejected("The folder sync pair no longer exists.") + if (initialPair.accountId != desktopFileCacheAccountId(session)) { + return FileSyncCenterActionResult.Rejected("This folder sync pair belongs to another account.") + } + val root = persisted.roots.firstOrNull { it.id == initialPair.localRootId } + ?: return FileSyncCenterActionResult.Rejected("The local folder record is missing.") + val local = DesktopFileSyncLocalTree(File(root.absolutePath)) + val remote = DesktopFileSyncRemoteTree(session, userId, initialPair.remoteRootPath) + val includes: (String, SyncEntryKind) -> Boolean = { path, kind -> + initialPair.configuration.includesSyncPath(path, kind) + } + val cachedLocalRevisions = initialPair.baselines.mapNotNull { baseline -> + baseline.localRevision?.let { revision -> baseline.relativePath to revision } + }.toMap() + val localEntries = local.scan(cachedLocalRevisions, includes).map(DesktopLocalSyncDocument::entry) + val remoteEntries = remote.scan(includes).map(DesktopRemoteSyncDocument::entry) + persisted = persisted.copy( + coordinator = scanFileSyncPair( + persisted.coordinator, + pairId, + localEntries, + remoteEntries, + System.currentTimeMillis(), + ), + ) + if (resetExhaustedFailures) { + persisted = persisted.copy( + coordinator = resetExhaustedFileSyncOperations(persisted.coordinator, pairId), + ) + } + persisted.coordinator.pairs.first { it.id == pairId }.workItems + .filter { it.state == FileSyncExecutionState.Failed && it.attemptCount < MAX_FILE_SYNC_ATTEMPTS } + .forEach { work -> + persisted = persisted.copy( + coordinator = retryFileSyncOperation(persisted.coordinator, pairId, work.id), + ) + } + store.save(persisted) + + val pairLabel = syncPairLabel(root.displayName, initialPair.remoteRootPath) + val totalOperations = persisted.coordinator.pairs.first { it.id == pairId }.workItems.count { + it.state == FileSyncExecutionState.Ready + } + var completed = 0 + while (true) { + if (!shouldContinue()) break + val claim = claimNextFileSyncOperation(persisted.coordinator, pairId, System.currentTimeMillis()) + persisted = persisted.copy(coordinator = claim.state) + store.save(persisted) + val command = claim.command ?: break + val runningWork = persisted.coordinator.pairs.first { it.id == pairId } + .workItems.first { it.id == command.workId } + val sizeBytes = runningWork.observedLocal?.size ?: runningWork.observedRemote?.size + onProgress( + DesktopFileSyncProgressEvent( + pairId = pairId, + workId = command.workId, + relativePath = runningWork.relativePath, + pairLabel = pairLabel, + operation = command.operation, + completedOperations = completed, + totalOperations = totalOperations, + sizeBytes = sizeBytes, + stage = DesktopFileSyncProgressStage.Started, + ), + ) + try { + val success = execute(command, persisted.coordinator, local, remote) + persisted = persisted.copy( + coordinator = completeFileSyncOperation( + persisted.coordinator, + pairId, + command.workId, + success, + ), + ) + store.save(persisted) + completed += 1 + onProgress( + DesktopFileSyncProgressEvent( + pairId = pairId, + workId = command.workId, + relativePath = runningWork.relativePath, + pairLabel = pairLabel, + operation = command.operation, + completedOperations = completed, + totalOperations = totalOperations, + sizeBytes = sizeBytes, + stage = DesktopFileSyncProgressStage.Completed, + ), + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (failure: Throwable) { + val safeMessage = safeFailureMessage(failure, "The sync operation failed.") + persisted = persisted.copy( + coordinator = failFileSyncOperation( + persisted.coordinator, + pairId, + command.workId, + safeMessage, + ), + ) + store.save(persisted) + onProgress( + DesktopFileSyncProgressEvent( + pairId = pairId, + workId = command.workId, + relativePath = runningWork.relativePath, + pairLabel = pairLabel, + operation = command.operation, + completedOperations = completed, + totalOperations = totalOperations, + sizeBytes = sizeBytes, + stage = DesktopFileSyncProgressStage.Failed, + failureMessage = safeMessage, + ), + ) + } + } + val pair = persisted.coordinator.pairs.first { it.id == pairId } + val conflicts = pair.workItems.count { it.state == FileSyncExecutionState.AwaitingDecision } + val failures = pair.workItems.count { it.state == FileSyncExecutionState.Failed } + val message = buildString { + append(completed).append(" sync operation") + if (completed != 1) append('s') + append(" completed.") + if (conflicts > 0) append(' ').append(conflicts).append(" conflicts need review.") + if (failures > 0) append(' ').append(failures).append(" operations failed.") + } + return if (failures > 0) FileSyncCenterActionResult.Rejected(message) + else FileSyncCenterActionResult.Completed(message) + } + + private fun syncPairLabel(localDisplayName: String, remoteRootPath: String): String = + "$localDisplayName to /${remoteRootPath.ifBlank { "Nextcloud" }}" + + private fun execute( + command: FileSyncExecutionCommand, + state: FileSyncCoordinatorState, + local: DesktopFileSyncLocalTree, + remote: DesktopFileSyncRemoteTree, + ): FileSyncExecutionSuccess { + val pair = state.pairs.first { it.id == command.pairId } + val work = pair.workItems.first { it.id == command.workId } + return when (val operation = command.operation) { + is FileSyncOperation.Upload -> { + val source = requireNotNull(work.observedLocal) + val replacingType = work.observedRemote?.kind?.let { it != source.kind } == true + var exactLocal: LocalSyncEntry? = null + var exactRemote: RemoteSyncEntry? = null + if (source.kind == SyncEntryKind.Directory && replacingType) { + remote.replaceWithDirectory( + operation.relativePath, + requireNotNull(operation.expectedRemoteEtag), + ) + } else if (source.kind == SyncEntryKind.Directory) { + remote.createDirectory(operation.relativePath, operation.expectedRemoteEtag) + } else { + withStagingFile("upload") { staged -> + exactLocal = local.stageForUpload(operation.relativePath, staged, MAX_SYNC_FILE_BYTES) + val uploaded = if (replacingType) { + remote.replaceWithFile( + operation.relativePath, + staged, + requireNotNull(operation.expectedRemoteEtag), + ) + } else { + remote.writeFile(operation.relativePath, staged, operation.expectedRemoteEtag) + } + withStagingFile("verify-upload") { verified -> + exactRemote = remote.stageDownload( + operation.relativePath, + uploaded.etag, + verified, + MAX_SYNC_FILE_BYTES, + ) + require(filesMatch(staged, verified)) { + "The uploaded server file does not match the staged local generation." + } + } + } + } + if (source.kind == SyncEntryKind.File) { + FileSyncExecutionSuccess( + synchronizedBaselines = listOf( + FileSyncBaseline( + operation.relativePath, + SyncEntryKind.File, + requireNotNull(exactLocal).revision, + requireNotNull(exactRemote).etag, + ), + ), + ) + } else { + synchronizedResult(operation.relativePath, local, remote) + } + } + is FileSyncOperation.Download -> { + val source = requireNotNull(work.observedRemote) + val replacingType = work.observedLocal?.kind?.let { it != source.kind } == true + var exactLocal: LocalSyncEntry? = null + var exactRemote: RemoteSyncEntry? = null + if (source.kind == SyncEntryKind.Directory && replacingType) { + local.replaceWithDirectory( + operation.relativePath, + requireNotNull(operation.expectedLocalRevision), + ) + } else if (source.kind == SyncEntryKind.Directory) { + local.createDirectory(operation.relativePath, operation.expectedLocalRevision) + } else { + source.size?.let { size -> requireDownloadCapacity(local, operation.relativePath, size) } + withStagingFile("download") { staged -> + exactRemote = remote.stageDownload( + operation.relativePath, + source.etag, + staged, + MAX_SYNC_FILE_BYTES, + ) { declaredBytes -> + requireDownloadCapacity( + local, + operation.relativePath, + declaredBytes ?: source.size ?: MAX_SYNC_FILE_BYTES, + ) + } + exactLocal = if (replacingType) { + local.replaceWithFile( + operation.relativePath, + staged, + requireNotNull(operation.expectedLocalRevision), + ) + } else { + local.writeFile(operation.relativePath, staged, operation.expectedLocalRevision) + } + } + } + if (source.kind == SyncEntryKind.File) { + FileSyncExecutionSuccess( + synchronizedBaselines = listOf( + FileSyncBaseline( + operation.relativePath, + SyncEntryKind.File, + requireNotNull(exactLocal).revision, + requireNotNull(exactRemote).etag, + ), + ), + ) + } else { + synchronizedResult(operation.relativePath, local, remote) + } + } + is FileSyncOperation.DeleteLocal -> { + local.delete(operation.relativePath, operation.expectedLocalRevision) + require(local.resolve(operation.relativePath) == null && remote.resolve(operation.relativePath) == null) + FileSyncExecutionSuccess(removedRelativePaths = listOf(operation.relativePath)) + } + is FileSyncOperation.DeleteRemote -> { + remote.delete(operation.relativePath, operation.expectedRemoteEtag) + require(local.resolve(operation.relativePath) == null && remote.resolve(operation.relativePath) == null) + FileSyncExecutionSuccess(removedRelativePaths = listOf(operation.relativePath)) + } + is FileSyncOperation.KeepBoth -> executeKeepBoth(operation, work, local, remote) + is FileSyncOperation.NeedsDecision, + is FileSyncOperation.Skipped, + -> error("Non-executable sync work was claimed.") + } + } + + private fun executeKeepBoth( + operation: FileSyncOperation.KeepBoth, + work: FileSyncWorkItem, + local: DesktopFileSyncLocalTree, + remote: DesktopFileSyncRemoteTree, + ): FileSyncExecutionSuccess { + val localSource = requireNotNull(work.observedLocal) + val remoteSource = requireNotNull(work.observedRemote) + require(localSource.kind == SyncEntryKind.File && remoteSource.kind == SyncEntryKind.File) + withStagingFile("keep-local") { localBytes -> + withStagingFile("keep-remote") { remoteBytes -> + val currentOriginal = local.resolve(operation.relativePath) + val preservedLocalPath = if (currentOriginal?.entry?.revision == localSource.revision) { + operation.relativePath + } else { + operation.localConflictPath + } + local.stageForUpload(preservedLocalPath, localBytes, MAX_SYNC_FILE_BYTES) + remote.stageDownload(operation.relativePath, remoteSource.etag, remoteBytes, MAX_SYNC_FILE_BYTES) + ensureLocalFile(operation.localConflictPath, localBytes, local) + ensureRemoteFile(operation.localConflictPath, localBytes, remote) + ensureLocalFile(operation.remoteConflictPath, remoteBytes, local) + ensureRemoteFile(operation.remoteConflictPath, remoteBytes, remote) + replaceLocalOriginalOrVerify( + operation.relativePath, + remoteBytes, + localSource.revision, + local, + ) + } + } + return FileSyncExecutionSuccess( + synchronizedBaselines = listOf( + verifiedBaseline(operation.relativePath, local, remote), + verifiedBaseline(operation.localConflictPath, local, remote), + verifiedBaseline(operation.remoteConflictPath, local, remote), + ), + ) + } + + private fun ensureLocalFile( + path: String, + expectedBytes: File, + local: DesktopFileSyncLocalTree, + ) { + val current = local.resolve(path) + if (current == null) { + local.writeFile(path, expectedBytes, null) + return + } + require(current.entry.kind == SyncEntryKind.File) { "A conflict-copy path is not a file." } + withStagingFile("verify-local-conflict") { actualBytes -> + local.stageForUpload(path, actualBytes, MAX_SYNC_FILE_BYTES) + require(filesMatch(actualBytes, expectedBytes)) { + "A conflict-copy path contains different local content." + } + } + } + + private fun ensureRemoteFile( + path: String, + expectedBytes: File, + remote: DesktopFileSyncRemoteTree, + ) { + val current = remote.resolve(path) + if (current == null) { + remote.writeFile(path, expectedBytes, null) + return + } + require(current.entry.kind == SyncEntryKind.File) { "A conflict-copy path is not a file." } + withStagingFile("verify-remote-conflict") { actualBytes -> + remote.stageDownload(path, current.entry.etag, actualBytes, MAX_SYNC_FILE_BYTES) + require(filesMatch(actualBytes, expectedBytes)) { + "A conflict-copy path contains different server content." + } + } + } + + private fun replaceLocalOriginalOrVerify( + path: String, + expectedBytes: File, + originalRevision: String, + local: DesktopFileSyncLocalTree, + ) { + val current = requireNotNull(local.resolve(path)) { "The original local file disappeared." } + require(current.entry.kind == SyncEntryKind.File) { "The original local path is not a file." } + if (current.entry.revision == originalRevision) { + local.writeFile(path, expectedBytes, originalRevision) + return + } + withStagingFile("verify-local-original") { actualBytes -> + local.stageForUpload(path, actualBytes, MAX_SYNC_FILE_BYTES) + require(filesMatch(actualBytes, expectedBytes)) { + "The original local file changed while conflict copies were being published." + } + } + } + + private fun filesMatch(first: File, second: File): Boolean = + first.length() == second.length() && Files.mismatch(first.toPath(), second.toPath()) == -1L + + private fun synchronizedResult( + path: String, + local: DesktopFileSyncLocalTree, + remote: DesktopFileSyncRemoteTree, + ) = FileSyncExecutionSuccess(synchronizedBaselines = listOf(verifiedBaseline(path, local, remote))) + + private fun verifiedBaseline( + path: String, + local: DesktopFileSyncLocalTree, + remote: DesktopFileSyncRemoteTree, + ): FileSyncBaseline { + val localEntry = requireNotNull(local.resolve(path)) { "The local result could not be verified." }.entry + val remoteEntry = requireNotNull(remote.resolve(path)) { "The server result could not be verified." }.entry + require(localEntry.kind == remoteEntry.kind) { "The synchronized item types do not match." } + return FileSyncBaseline(path, localEntry.kind, localEntry.revision, remoteEntry.etag) + } + + private inline fun withStagingFile(prefix: String, block: (File) -> T): T { + check(stagingRoot.isDirectory || stagingRoot.mkdirs()) { "Could not create sync staging storage." } + require(prefix in DESKTOP_FILE_SYNC_STAGE_PREFIXES) + val file = File(stagingRoot, "nextcloud-native-$prefix-${UUID.randomUUID()}.tmp") + check(file.createNewFile()) { "Could not create sync staging file." } + return try { + block(file) + } finally { + file.delete() + } + } + + private fun requireDownloadCapacity( + local: DesktopFileSyncLocalTree, + relativePath: String, + downloadBytes: Long, + ) { + require(downloadBytes in 0L..MAX_SYNC_FILE_BYTES) + val reserve = minimumFreeSpaceBytes() + require(reserve >= 0L) + check(stagingRoot.isDirectory || stagingRoot.mkdirs()) { "Could not create sync staging storage." } + val stagingStore = Files.getFileStore(stagingRoot.toPath()) + val destinationStore = local.fileStore(relativePath) + if (stagingStore == destinationStore) { + require( + stagingStore.usableSpace >= requiredDesktopDownloadFreeBytes(downloadBytes, reserve, sameStore = true), + ) { "There is not enough free space to stage this synchronized file safely." } + } else { + val required = requiredDesktopDownloadFreeBytes(downloadBytes, reserve, sameStore = false) + require(stagingStore.usableSpace >= required) { + "The sync staging location does not have enough reserved free space." + } + require(destinationStore.usableSpace >= required) { + "The destination folder does not have enough reserved free space." + } + } + } + + private fun normalizeRemoteRoot(path: String): String { + val normalized = path.trim().trim('/') + if (normalized.isEmpty()) return "" + require(normalized.length <= MAX_FILE_SYNC_PATH_LENGTH) + normalized.split('/').forEach { segment -> + require(segment.isNotBlank() && segment !in setOf(".", "..") && segment.none(Char::isISOControl)) + } + return normalized + } + + private fun safeFailureMessage(failure: Throwable, fallback: String): String = + failure.message?.map { if (it.isISOControl()) ' ' else it }?.joinToString("") + ?.trim()?.take(MAX_FILE_SYNC_FAILURE_LENGTH)?.takeIf(String::isNotBlank) ?: fallback + + private companion object { + const val MAX_SYNC_FILE_BYTES = 8L * 1024L * 1024L * 1024L + } +} + +internal fun reclaimDesktopFileSyncStages(stagingRoot: File): Int { + if (!stagingRoot.isDirectory) return 0 + return stagingRoot.listFiles().orEmpty().count { candidate -> + if (!Files.isRegularFile(candidate.toPath(), LinkOption.NOFOLLOW_LINKS)) return@count false + val name = candidate.name + val prefix = DESKTOP_FILE_SYNC_STAGE_PREFIXES.firstOrNull { ownedPrefix -> + name.startsWith("nextcloud-native-$ownedPrefix-") + } ?: return@count false + val token = name.removePrefix("nextcloud-native-$prefix-").removeSuffix(".tmp") + if (!name.endsWith(".tmp") || runCatching { UUID.fromString(token) }.isFailure) return@count false + candidate.delete() + } +} + +private val DESKTOP_FILE_SYNC_STAGE_PREFIXES = setOf( + "upload", + "verify-upload", + "download", + "keep-local", + "keep-remote", + "verify-local-conflict", + "verify-remote-conflict", + "verify-local-original", +) + +internal fun requiredDesktopDownloadFreeBytes( + downloadBytes: Long, + reserveBytes: Long, + sameStore: Boolean, +): Long { + require(downloadBytes >= 0L && reserveBytes >= 0L) + val contentBytes = if (sameStore) { + if (downloadBytes > Long.MAX_VALUE / 2L) Long.MAX_VALUE else downloadBytes * 2L + } else { + downloadBytes + } + return if (reserveBytes > Long.MAX_VALUE - contentBytes) Long.MAX_VALUE else contentBytes + reserveBytes +} + +internal fun desktopSyncRootsOverlap(first: String, second: String): Boolean { + val firstPath = File(first).toPath().toAbsolutePath().normalize() + val secondPath = File(second).toPath().toAbsolutePath().normalize() + return firstPath == secondPath || firstPath.startsWith(secondPath) || secondPath.startsWith(firstPath) +} + +internal fun desktopSyncRemoteRootsOverlap(first: String, second: String): Boolean { + val left = first.trim('/') + val right = second.trim('/') + return left.isEmpty() || right.isEmpty() || + left == right || left.startsWith("$right/") || right.startsWith("$left/") +} + +internal fun desktopSyncMappingsOverlap( + existingAccountId: String, + requestedAccountId: String, + existingLocalRoot: String, + requestedLocalRoot: String, + existingRemoteRoot: String, + requestedRemoteRoot: String, +): Boolean = desktopSyncRootsOverlap(existingLocalRoot, requestedLocalRoot) || + ( + existingAccountId == requestedAccountId && + desktopSyncRemoteRootsOverlap(existingRemoteRoot, requestedRemoteRoot) + ) + +private fun desktopFileSyncStagingDirectory(): File { + val cacheRoot = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank)?.let(::File) + ?: File(System.getProperty("user.home"), ".cache") + return File(cacheRoot, "nextcloud-native/file-sync-staging") +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTree.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTree.kt index f7364a90a..b0e4cdf40 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTree.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTree.kt @@ -1,9 +1,12 @@ package dev.obiente.nextcloudnative.app import java.io.File -import java.io.FileInputStream import java.io.FileOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.nio.channels.Channels import java.nio.channels.FileChannel +import java.nio.file.FileStore import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.FileVisitResult import java.nio.file.Files @@ -15,6 +18,7 @@ import java.nio.file.StandardOpenOption import java.nio.file.attribute.BasicFileAttributes import java.security.MessageDigest import java.util.UUID +import java.util.concurrent.ConcurrentHashMap internal data class DesktopLocalSyncDocument( val entry: LocalSyncEntry, @@ -22,17 +26,30 @@ internal data class DesktopLocalSyncDocument( ) /** Revision-guarded, symlink-rejecting local filesystem adapter for desktop folder sync. */ -internal class DesktopFileSyncLocalTree(root: File) { +internal class DesktopFileSyncLocalTree( + root: File, + private val changeTokenProvider: (Path) -> String? = ::desktopFileChangeToken, + private val contentDigester: (Path) -> String = ::desktopSha256File, +) { private val root = root.toPath().toAbsolutePath().normalize() + private val knownDirectoryIdentities = ConcurrentHashMap() init { require(Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS)) { "The selected desktop sync folder is no longer available." } require(!Files.isSymbolicLink(this.root)) { "A symbolic link cannot be used as a sync root." } + rememberOrRequireDirectoryIdentity( + this.root, + Files.readAttributes(this.root, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS), + ) } - fun scan(): List { + fun scan( + cachedLocalRevisions: Map = emptyMap(), + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean = { _, _ -> true }, + ): List { + requireSafeAncestors(root, includeLeaf = true, allowMissingTail = false) recoverOwnedStagingFiles() val result = ArrayList() Files.walkFileTree( @@ -44,30 +61,46 @@ internal class DesktopFileSyncLocalTree(root: File) { require(!Files.isSymbolicLink(dir)) { "Folder sync stopped because ${relative(dir)} is a symbolic link." } - if (dir != root) add(dir, attrs, SyncEntryKind.Directory) + rememberOrRequireDirectoryIdentity(dir, attrs) + if (dir != root) { + if (isOwnedRecoveryPath(dir)) return FileVisitResult.SKIP_SUBTREE + val relative = relative(dir) + if (!includes(relative, SyncEntryKind.Directory)) return FileVisitResult.SKIP_SUBTREE + add(dir, attrs, SyncEntryKind.Directory) + } return FileVisitResult.CONTINUE } override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (isOwnedRecoveryPath(file)) return FileVisitResult.CONTINUE require(!Files.isSymbolicLink(file)) { "Folder sync stopped because ${relative(file)} is a symbolic link." } require(attrs.isRegularFile) { "Folder sync stopped because ${relative(file)} is not a regular file." } - add(file, attrs, SyncEntryKind.File) + if (includes(relative(file), SyncEntryKind.File)) add(file, attrs, SyncEntryKind.File) return FileVisitResult.CONTINUE } private fun add(path: Path, attrs: BasicFileAttributes, kind: SyncEntryKind) { require(result.size < MAX_ENTRIES) { "The desktop folder contains too many entries." } val relative = relative(path) + val metadata = metadataDigest(path, attrs) + val contentDigest = path.takeIf { kind == SyncEntryKind.File }?.let { + reusableContentDigest(cachedLocalRevisions[relative], metadata) + ?: run { + requireSafeAncestors(path, includeLeaf = true, allowMissingTail = false) + contentDigester(path) + } + } result += DesktopLocalSyncDocument( LocalSyncEntry( relativePath = relative, kind = kind, - revision = revision(path, attrs), + revision = revision(metadata.value, contentDigest), size = attrs.size().takeIf { kind == SyncEntryKind.File }, + contentHash = contentDigest?.let { "sha256:$it" }, ), path, ) @@ -79,6 +112,7 @@ internal class DesktopFileSyncLocalTree(root: File) { fun resolve(relativePath: String): DesktopLocalSyncDocument? { val path = safePath(relativePath) + requireSafeAncestors(path, includeLeaf = true, allowMissingTail = true) if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return null require(!Files.isSymbolicLink(path)) { "The local item changed into a symbolic link." } val attrs = Files.readAttributes(path, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) @@ -87,25 +121,46 @@ internal class DesktopFileSyncLocalTree(root: File) { attrs.isRegularFile -> SyncEntryKind.File else -> error("The local item is not a regular file or folder.") } + val metadata = metadataDigest(path, attrs) + if (kind == SyncEntryKind.Directory) rememberOrRequireDirectoryIdentity(path, attrs) + val contentDigest = path.takeIf { kind == SyncEntryKind.File }?.let { + requireSafeAncestors(path, includeLeaf = true, allowMissingTail = false) + contentDigester(path) + } return DesktopLocalSyncDocument( LocalSyncEntry( relativePath, kind, - revision(path, attrs), + revision(metadata.value, contentDigest), attrs.size().takeIf { kind == SyncEntryKind.File }, + contentDigest?.let { "sha256:$it" }, ), path, ) } + fun fileStore(relativePath: String): FileStore { + val destination = safePath(relativePath) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = true) + val existingParent = generateSequence(destination.parent) { parent -> parent.parent } + .first { parent -> Files.exists(parent, LinkOption.NOFOLLOW_LINKS) } + return Files.getFileStore(existingParent) + } + fun stageForUpload(relativePath: String, destination: File, maximumBytes: Long): LocalSyncEntry { val before = requireNotNull(resolve(relativePath)) { "The local file no longer exists." } require(before.entry.kind == SyncEntryKind.File) require((before.entry.size ?: 0L) <= maximumBytes) { "The local file exceeds the sync size limit." } - FileInputStream(before.path.toFile()).use { input -> - FileOutputStream(destination).use { output -> - copyBounded(input, output, maximumBytes) - output.fd.sync() + requireSafeAncestors(before.path, includeLeaf = true, allowMissingTail = false) + Files.newByteChannel( + before.path, + setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS), + ).use { channel -> + Channels.newInputStream(channel).use { input -> + FileOutputStream(destination).use { output -> + copyBounded(input, output, maximumBytes) + output.fd.sync() + } } } val after = requireNotNull(resolve(relativePath)) @@ -119,7 +174,10 @@ internal class DesktopFileSyncLocalTree(root: File) { val current = resolve(relativePath) if (expectedLocalRevision == null) { require(current == null) { "The local folder appeared after the sync scan." } - Files.createDirectories(safePath(relativePath)) + val destination = safePath(relativePath) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = true) + createSafeDirectories(destination) + requireSafeAncestors(destination, includeLeaf = true, allowMissingTail = false) } else { require(current?.entry?.revision == expectedLocalRevision) { "The local folder changed after the sync scan." @@ -128,7 +186,7 @@ internal class DesktopFileSyncLocalTree(root: File) { } } - fun writeFile(relativePath: String, source: File, expectedLocalRevision: String?) { + fun writeFile(relativePath: String, source: File, expectedLocalRevision: String?): LocalSyncEntry { val destination = safePath(relativePath) val current = resolve(relativePath) if (expectedLocalRevision == null) { @@ -140,23 +198,95 @@ internal class DesktopFileSyncLocalTree(root: File) { require(current.entry.kind == SyncEntryKind.File) } val parent = requireNotNull(destination.parent) - Files.createDirectories(parent) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = true) + createSafeDirectories(parent) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = false) + return publishFileReplacement(destination, current, source) + } + + fun replaceWithFile(relativePath: String, source: File, expectedLocalRevision: String): LocalSyncEntry { + val destination = safePath(relativePath) + val current = requireNotNull(resolve(relativePath)) { "The local item was already removed." } + require(current.entry.revision == expectedLocalRevision) { + "The local item changed after the sync scan." + } + require(current.entry.kind == SyncEntryKind.Directory) { + "The local item type changed after the sync scan." + } + return publishFileReplacement(destination, current, source) + } + + fun replaceWithDirectory(relativePath: String, expectedLocalRevision: String) { + val destination = safePath(relativePath) + val current = requireNotNull(resolve(relativePath)) { "The local item was already removed." } + require(current.entry.revision == expectedLocalRevision) { + "The local item changed after the sync scan." + } + require(current.entry.kind == SyncEntryKind.File) { + "The local item type changed after the sync scan." + } + val parent = requireNotNull(destination.parent) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = false) + val token = UUID.randomUUID().toString() + val backup = parent.resolve(".${destination.fileName}.nextcloud-native-backup-$token") + var protected = false + try { + requireSafeAncestors(destination, includeLeaf = true, allowMissingTail = false) + requireUnchanged(current) + move(current.path, backup, replace = false) + protected = true + forgetDirectoryIdentitiesWithin(destination) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = false) + Files.createDirectory(destination) + requireSafeAncestors(destination, includeLeaf = true, allowMissingTail = false) + } catch (failure: Throwable) { + if (protected && !Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + runCatching { move(backup, destination, replace = false) } + } + throw failure + } + deleteOwnedPath(backup) + } + + private fun publishFileReplacement( + destination: Path, + current: DesktopLocalSyncDocument?, + source: File, + ): LocalSyncEntry { + val parent = requireNotNull(destination.parent) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = false) + val expectedContentHash = "sha256:${contentDigester(source.toPath())}" val token = UUID.randomUUID().toString() val staged = parent.resolve(".${destination.fileName}.nextcloud-native-download-$token") val backup = parent.resolve(".${destination.fileName}.nextcloud-native-backup-$token") Files.copy(source.toPath(), staged, StandardCopyOption.REPLACE_EXISTING) - FileChannel.open(staged, StandardOpenOption.WRITE).use { it.force(true) } + requireSafeAncestors(staged, includeLeaf = true, allowMissingTail = false) + FileChannel.open(staged, setOf(StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)).use { it.force(true) } var protected = false try { if (current != null) { + requireSafeAncestors(current.path, includeLeaf = true, allowMissingTail = false) + requireUnchanged(current) move(current.path, backup, replace = false) protected = true + if (current.entry.kind == SyncEntryKind.Directory) { + forgetDirectoryIdentitiesWithin(destination) + } } move(staged, destination, replace = false) - if (protected) Files.deleteIfExists(backup) + val published = requireNotNull(resolve(relative(destination))) { + "The published local file disappeared." + }.entry + require(published.kind == SyncEntryKind.File && published.contentHash == expectedContentHash) { + "The local file changed while its synchronized revision was being recorded." + } + if (protected) deleteOwnedPath(backup) + return published } catch (failure: Throwable) { Files.deleteIfExists(staged) - if (protected && !Files.exists(destination)) runCatching { move(backup, destination, replace = false) } + if (protected && !Files.exists(destination, LinkOption.NOFOLLOW_LINKS)) { + runCatching { move(backup, destination, replace = false) } + } throw failure } } @@ -166,7 +296,10 @@ internal class DesktopFileSyncLocalTree(root: File) { require(current.entry.revision == expectedLocalRevision) { "The local item changed after the sync scan." } + requireSafeAncestors(current.path, includeLeaf = true, allowMissingTail = false) + requireUnchanged(current) Files.delete(current.path) + if (current.entry.kind == SyncEntryKind.Directory) forgetDirectoryIdentitiesWithin(current.path) } private fun recoverOwnedStagingFiles() { @@ -175,19 +308,24 @@ internal class DesktopFileSyncLocalTree(root: File) { setOf(), MAX_DEPTH, object : SimpleFileVisitor() { + override fun preVisitDirectory(dir: Path, attrs: BasicFileAttributes): FileVisitResult { + val owned = ownedRecoveryPath(dir) + if (dir == root || owned?.kind != OwnedRecoveryKind.Backup) { + return FileVisitResult.CONTINUE + } + reconcileOwnedBackup(dir) + return if (Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) { + FileVisitResult.CONTINUE + } else { + FileVisitResult.SKIP_SUBTREE + } + } + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { - val name = file.fileName.toString() - when { - DOWNLOAD_MARKER in name -> Files.deleteIfExists(file) - BACKUP_MARKER in name -> { - val finalName = name.removePrefix(".").substringBefore(BACKUP_MARKER) - val finalPath = requireNotNull(file.parent).resolve(finalName) - if (Files.exists(finalPath, LinkOption.NOFOLLOW_LINKS)) { - Files.deleteIfExists(file) - } else { - move(file, finalPath, replace = false) - } - } + when (ownedRecoveryPath(file)?.kind) { + OwnedRecoveryKind.Download -> Files.deleteIfExists(file) + OwnedRecoveryKind.Backup -> reconcileOwnedBackup(file) + null -> Unit } return FileVisitResult.CONTINUE } @@ -195,6 +333,59 @@ internal class DesktopFileSyncLocalTree(root: File) { ) } + private fun reconcileOwnedBackup(backup: Path) { + val owned = ownedRecoveryPath(backup)?.takeIf { it.kind == OwnedRecoveryKind.Backup } ?: return + val finalPath = requireNotNull(backup.parent).resolve(owned.destinationName) + if (Files.exists(finalPath, LinkOption.NOFOLLOW_LINKS)) { + requireSafeAncestors(finalPath, includeLeaf = true, allowMissingTail = false) + val incompleteDownload = backup.parent.resolve( + ".${owned.destinationName}$DOWNLOAD_MARKER${owned.token}", + ) + if (!Files.exists(incompleteDownload, LinkOption.NOFOLLOW_LINKS)) deleteOwnedPath(backup) + } else { + move(backup, finalPath, replace = false) + } + } + + private fun ownedRecoveryPath(path: Path): OwnedRecoveryPath? { + val name = path.fileName.toString() + if (!name.startsWith('.')) return null + val candidates = listOf( + OwnedRecoveryKind.Download to DOWNLOAD_MARKER, + OwnedRecoveryKind.Backup to BACKUP_MARKER, + ) + return candidates.firstNotNullOfOrNull { (kind, marker) -> + val markerIndex = name.lastIndexOf(marker) + if (markerIndex <= 1) return@firstNotNullOfOrNull null + val token = name.substring(markerIndex + marker.length) + if (runCatching { UUID.fromString(token) }.isFailure) return@firstNotNullOfOrNull null + val destinationName = name.substring(1, markerIndex) + destinationName.takeIf(String::isNotBlank)?.let { OwnedRecoveryPath(kind, it, token) } + } + } + + private fun isOwnedRecoveryPath(path: Path): Boolean = ownedRecoveryPath(path) != null + + private fun deleteOwnedPath(path: Path) { + require(path.startsWith(root) && ownedRecoveryPath(path)?.kind == OwnedRecoveryKind.Backup) + requireSafeAncestors(path, includeLeaf = true, allowMissingTail = false) + Files.walkFileTree( + path, + object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(dir: Path, failure: java.io.IOException?): FileVisitResult { + failure?.let { throw it } + Files.delete(dir) + return FileVisitResult.CONTINUE + } + }, + ) + } + private fun safePath(relativePath: String): Path { requireValidSyncPath(relativePath) val resolved = root.resolve(relativePath).normalize() @@ -202,27 +393,111 @@ internal class DesktopFileSyncLocalTree(root: File) { return resolved } + private fun requireSafeAncestors(path: Path, includeLeaf: Boolean, allowMissingTail: Boolean) { + val normalized = path.toAbsolutePath().normalize() + require(normalized == root || normalized.startsWith(root)) { "The local sync path escaped its root." } + val relative = root.relativize(normalized) + var current = root + val components = relative.toList() + val lastDirectoryIndex = if (includeLeaf) components.lastIndex else components.lastIndex - 1 + for (index in -1..lastDirectoryIndex) { + if (index >= 0) current = current.resolve(components[index]) + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + require(allowMissingTail) { "The local sync path disappeared before it could be used." } + return + } + require(!Files.isSymbolicLink(current)) { + "Folder sync stopped because an item in the local path became a symbolic link." + } + val attrs = Files.readAttributes(current, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + if (index < lastDirectoryIndex || normalized == root || !includeLeaf) { + require(attrs.isDirectory) { "Folder sync stopped because a local path parent is no longer a folder." } + rememberOrRequireDirectoryIdentity(current, attrs) + } else if (attrs.isDirectory) { + rememberOrRequireDirectoryIdentity(current, attrs) + } else { + require(attrs.isRegularFile) { "Folder sync stopped because a local item is no longer a regular file." } + } + } + } + + private fun rememberOrRequireDirectoryIdentity(path: Path, attrs: BasicFileAttributes) { + require(attrs.isDirectory) + val key = if (path == root) "" else relative(path) + val identity = LocalDirectoryIdentity(attrs.fileKey()?.toString(), attrs.creationTime().toMillis()) + val known = knownDirectoryIdentities.putIfAbsent(key, identity) + require(known == null || known == identity) { + "Folder sync stopped because a local path parent was replaced after it was scanned." + } + } + + private fun forgetDirectoryIdentitiesWithin(path: Path) { + val prefix = relative(path) + knownDirectoryIdentities.keys.removeIf { key -> key == prefix || key.startsWith("$prefix/") } + } + + private fun requireUnchanged(expected: DesktopLocalSyncDocument) { + val current = requireNotNull(resolve(expected.entry.relativePath)) { + "The local item disappeared before it could be changed." + } + require(current.entry.revision == expected.entry.revision) { + "The local item changed immediately before the synchronized operation." + } + } + + private fun createSafeDirectories(directory: Path) { + val normalized = directory.toAbsolutePath().normalize() + require(normalized == root || normalized.startsWith(root)) { "The local sync path escaped its root." } + var current = root + root.relativize(normalized).forEach { component -> + current = current.resolve(component) + if (!Files.exists(current, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(current) + } + requireSafeAncestors(current, includeLeaf = true, allowMissingTail = false) + } + } + private fun relative(path: Path): String = root.relativize(path.toAbsolutePath().normalize()).joinToString("/") { it.toString() } - private fun revision(path: Path, attrs: BasicFileAttributes): String { + private fun metadataDigest(path: Path, attrs: BasicFileAttributes): LocalMetadataDigest { + val changeTime = changeTokenProvider(path) val fingerprint = buildString { append(attrs.fileKey()?.toString().orEmpty()) append('\u0000') - append(attrs.lastModifiedTime().toMillis()) + append(attrs.lastModifiedTime()) + append('\u0000') + append(changeTime.orEmpty()) append('\u0000') append(attrs.size()) append('\u0000') append(attrs.isDirectory) append('\u0000') append(relative(path)) + append('\u0000') } - return "desktop-" + MessageDigest.getInstance("SHA-256") + val digest = MessageDigest.getInstance("SHA-256") .digest(fingerprint.encodeToByteArray()) .joinToString("") { "%02x".format(it) } + return LocalMetadataDigest(digest, reusable = changeTime != null) + } + + private fun revision(metadataDigest: String, contentDigest: String?): String = + "$REVISION_PREFIX:$metadataDigest:${contentDigest.orEmpty()}" + + private fun reusableContentDigest(previousRevision: String?, metadata: LocalMetadataDigest): String? { + if (previousRevision == null || !metadata.reusable) return null + val fields = previousRevision.split(':') + if (fields.size != 3 || fields[0] != REVISION_PREFIX || fields[1] != metadata.value) return null + return fields[2].takeIf { digest -> + digest.length == SHA256_HEX_LENGTH && digest.all { it in '0'..'9' || it in 'a'..'f' } + } } private fun move(source: Path, destination: Path, replace: Boolean) { + requireSafeAncestors(source, includeLeaf = true, allowMissingTail = false) + requireSafeAncestors(destination, includeLeaf = false, allowMissingTail = false) val options = buildList { add(StandardCopyOption.ATOMIC_MOVE) if (replace) add(StandardCopyOption.REPLACE_EXISTING) @@ -239,8 +514,8 @@ internal class DesktopFileSyncLocalTree(root: File) { } private fun copyBounded( - input: FileInputStream, - output: FileOutputStream, + input: InputStream, + output: OutputStream, maximumBytes: Long, ) { var total = 0L @@ -255,6 +530,8 @@ internal class DesktopFileSyncLocalTree(root: File) { } private companion object { + const val REVISION_PREFIX = "desktop-v2" + const val SHA256_HEX_LENGTH = 64 const val MAX_ENTRIES = 20_000 const val MAX_DEPTH = 64 const val BUFFER_BYTES = 64 * 1024 @@ -262,3 +539,37 @@ internal class DesktopFileSyncLocalTree(root: File) { const val BACKUP_MARKER = ".nextcloud-native-backup-" } } + +private data class LocalMetadataDigest(val value: String, val reusable: Boolean) + +private data class LocalDirectoryIdentity( + val fileKey: String?, + val creationTimeMillis: Long, +) + +private fun desktopFileChangeToken(path: Path): String? = runCatching { + Files.getAttribute(path, "unix:ctime", LinkOption.NOFOLLOW_LINKS).toString() +}.getOrNull() + +private fun desktopSha256File(path: Path): String { + val digest = MessageDigest.getInstance("SHA-256") + Files.newByteChannel(path, setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)).use { channel -> + Channels.newInputStream(channel).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + digest.update(buffer, 0, count) + } + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +private enum class OwnedRecoveryKind { Download, Backup } + +private data class OwnedRecoveryPath( + val kind: OwnedRecoveryKind, + val destinationName: String, + val token: String, +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt new file mode 100644 index 000000000..894d60404 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt @@ -0,0 +1,555 @@ +package dev.obiente.nextcloudnative.app + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.UUID +import javax.xml.parsers.DocumentBuilderFactory +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody + +internal data class DesktopRemoteSyncDocument( + val entry: RemoteSyncEntry, + val isDirectory: Boolean, +) + +/** Recursive, bounded and revision-guarded WebDAV adapter used by desktop sync. */ +internal class DesktopFileSyncRemoteTree( + private val session: NextcloudSession, + private val userId: String, + remoteRootPath: String, + private val client: OkHttpClient = desktopFileSyncHttpClient(), +) : LinuxVirtualWritebackRemote { + private val rootPath = remoteRootPath.trim('/') + + fun scan( + includes: (relativePath: String, kind: SyncEntryKind) -> Boolean = { _, _ -> true }, + ): List { + val result = ArrayList() + val pending = ArrayDeque() + pending += "" + while (pending.isNotEmpty()) { + val parent = pending.removeFirst() + require(parent.count { it == '/' } < MAX_DEPTH) { "The Nextcloud folder is nested too deeply." } + listDirectory(fullPath(parent)).forEach { document -> + val relativePath = toRelativePath(document.entry.relativePath) ?: return@forEach + val normalized = document.copy(entry = document.entry.copy(relativePath = relativePath)) + if (!includes(relativePath, normalized.entry.kind)) return@forEach + require(result.size < MAX_ENTRIES) { "The Nextcloud folder contains too many entries." } + result += normalized + if (normalized.isDirectory) pending += relativePath + } + } + return result.sortedBy { it.entry.relativePath } + } + + fun resolve(relativePath: String): DesktopRemoteSyncDocument? { + requireValidSyncPath(relativePath) + val parent = relativePath.substringBeforeLast('/', "") + val target = fullPath(relativePath) + return listDirectory(fullPath(parent)).firstOrNull { it.entry.relativePath == target } + ?.let { it.copy(entry = it.entry.copy(relativePath = relativePath)) } + } + + override fun resolveFile(relativePath: String): RemoteSyncEntry? = + resolve(relativePath)?.takeIf { !it.isDirectory }?.entry + + fun list(relativeDirectoryPath: String): List { + val normalizedDirectory = relativeDirectoryPath.trim('/') + if (normalizedDirectory.isNotBlank()) requireValidSyncPath(normalizedDirectory) + return listDirectory(fullPath(normalizedDirectory)).mapNotNull { document -> + val relativePath = toRelativePath(document.entry.relativePath) ?: return@mapNotNull null + document.copy(entry = document.entry.copy(relativePath = relativePath)) + }.sortedBy { it.entry.relativePath } + } + + override fun stageDownload( + relativePath: String, + expectedRemoteEtag: String, + destination: File, + maximumBytes: Long, + ): RemoteSyncEntry = stageDownload( + relativePath, + expectedRemoteEtag, + destination, + maximumBytes, + beforeTransfer = {}, + ) + + fun stageDownload( + relativePath: String, + expectedRemoteEtag: String, + destination: File, + maximumBytes: Long, + beforeTransfer: (declaredBytes: Long?) -> Unit, + ): RemoteSyncEntry { + require(maximumBytes > 0L) + val request = requestBuilder(fileUrl(fullPath(relativePath))) + .header("Accept", "application/octet-stream") + .header("If-Match", safeEtag(expectedRemoteEtag)) + .get() + .build() + client.newCall(request).execute().use { response -> + require(response.code == 200) { response.failure("download file") } + val declared = response.body.contentLength() + require(declared == -1L || declared <= maximumBytes) { "The server file exceeds the sync size limit." } + beforeTransfer(declared.takeIf { it >= 0L }) + FileOutputStream(destination).use { output -> + response.body.byteStream().copyBoundedTo(output, maximumBytes) + output.fd.sync() + } + response.header("ETag")?.let { returned -> + require(returned == expectedRemoteEtag) { "The server file changed while downloading." } + } + } + val after = requireNotNull(resolve(relativePath)) { "The server file disappeared while downloading." } + require(after.entry.etag == expectedRemoteEtag) { "The server file changed while downloading." } + return after.entry + } + + fun createDirectory(relativePath: String, expectedRemoteEtag: String?) { + val current = resolve(relativePath) + if (expectedRemoteEtag != null) { + require(current?.entry?.etag == expectedRemoteEtag && current.isDirectory) { + "The server folder changed after the sync scan." + } + return + } + require(current == null) { "The server folder appeared after the sync scan." } + execute( + requestBuilder(fileUrl(fullPath(relativePath))) + .header("If-None-Match", "*") + .method("MKCOL", EMPTY_BODY) + .build(), + "create folder", + ) + } + + fun replaceWithDirectory(relativePath: String, expectedRemoteEtag: String) { + val current = requireNotNull(resolve(relativePath)) { "The server item was already removed." } + require(current.entry.etag == expectedRemoteEtag && !current.isDirectory) { + "The server item changed after the sync scan." + } + val destinationPath = fullPath(relativePath) + val backupPath = replacementBackupPath(destinationPath) + val currentAtFullPath = current.withPath(destinationPath) + moveRemoteDocument(currentAtFullPath, backupPath) + try { + execute( + requestBuilder(fileUrl(destinationPath)) + .header("If-None-Match", "*") + .method("MKCOL", EMPTY_BODY) + .build(), + "replace item with folder", + ) + require(resolve(relativePath)?.isDirectory == true) { + "The replacement server folder could not be verified." + } + } catch (failure: Throwable) { + restoreRemoteBackup(destinationPath, backupPath) + throw failure + } + deleteRemoteBackup(backupPath) + } + + override fun writeFile(relativePath: String, source: File, expectedRemoteEtag: String?): RemoteSyncEntry { + require(source.isFile) + val current = resolve(relativePath) + if (expectedRemoteEtag == null) { + require(current == null) { "The server file appeared after the sync scan." } + createFile(fullPath(relativePath), source) + } else { + require(current?.entry?.etag == expectedRemoteEtag && !current.isDirectory) { + "The server file changed after the sync scan." + } + replaceFileAtomically(fullPath(relativePath), source, expectedRemoteEtag) + } + val after = requireNotNull(resolve(relativePath)) { "The uploaded server file disappeared." } + require(!after.isDirectory) { "The uploaded server item is not a file." } + return after.entry + } + + fun replaceWithFile(relativePath: String, source: File, expectedRemoteEtag: String): RemoteSyncEntry { + require(source.isFile) + val current = requireNotNull(resolve(relativePath)) { "The server item was already removed." } + require(current.entry.etag == expectedRemoteEtag && current.isDirectory) { + "The server item changed after the sync scan." + } + val destinationPath = fullPath(relativePath) + val parent = destinationPath.substringBeforeLast('/', "") + val token = UUID.randomUUID().toString() + val stagingPath = listOf(parent, ".nextcloud-native-$token.upload") + .filter(String::isNotBlank).joinToString("/") + val backupPath = replacementBackupPath(destinationPath) + val stagedEtag = createFile(stagingPath, source) + var protected = false + try { + moveRemoteDocument(current.withPath(destinationPath), backupPath) + protected = true + moveRemotePath( + sourcePath = stagingPath, + destinationPath = destinationPath, + sourceEtag = stagedEtag, + sourceIsDirectory = false, + ) + val after = requireNotNull(resolve(relativePath)) { "The uploaded server file disappeared." } + require(!after.isDirectory) { "The uploaded server item is not a file." } + deleteRemoteBackup(backupPath) + return after.entry + } catch (failure: Throwable) { + if (protected) restoreRemoteBackup(destinationPath, backupPath) + deleteRemoteStage(stagingPath, stagedEtag) + throw failure + } + } + + fun delete(relativePath: String, expectedRemoteEtag: String) { + val current = requireNotNull(resolve(relativePath)) { "The server item was already removed." } + require(current.entry.etag == expectedRemoteEtag) { "The server item changed after the sync scan." } + val url = fileUrl(fullPath(relativePath)) + val builder = requestBuilder(url) + if (current.isDirectory) builder.header("If", "<$url> ([$expectedRemoteEtag])") + else builder.header("If-Match", safeEtag(expectedRemoteEtag)) + execute(builder.delete().build(), "delete item") + } + + fun move(sourceRelativePath: String, destinationRelativePath: String, expectedRemoteEtag: String) { + requireValidSyncPath(sourceRelativePath) + requireValidSyncPath(destinationRelativePath) + require(resolve(destinationRelativePath) == null) { "The move destination already exists." } + val current = requireNotNull(resolve(sourceRelativePath)) { "The server item was already removed." } + require(current.entry.etag == expectedRemoteEtag) { "The server item changed before it could be moved." } + moveRemoteDocument(current.withPath(fullPath(sourceRelativePath)), fullPath(destinationRelativePath)) + } + + fun moveReplacing( + sourceRelativePath: String, + destinationRelativePath: String, + expectedSourceEtag: String, + expectedDestinationEtag: String, + ) { + requireValidSyncPath(sourceRelativePath) + requireValidSyncPath(destinationRelativePath) + val source = requireNotNull(resolve(sourceRelativePath)) { "The server source was already removed." } + val destination = requireNotNull(resolve(destinationRelativePath)) { + "The server destination was already removed." + } + require(source.entry.etag == expectedSourceEtag) { "The server source changed before it could be moved." } + require(destination.entry.etag == expectedDestinationEtag) { + "The server destination changed before it could be replaced." + } + require(source.isDirectory == destination.isDirectory) { "The move destination has a different item type." } + val sourcePath = fullPath(sourceRelativePath) + val destinationPath = fullPath(destinationRelativePath) + val backupPath = replacementBackupPath(destinationPath) + moveRemoteDocument(destination.withPath(destinationPath), backupPath) + try { + moveRemoteDocument(source.withPath(sourcePath), destinationPath) + val published = requireNotNull(resolve(destinationRelativePath)) { + "The moved server item could not be verified." + } + require(published.isDirectory == source.isDirectory) { "The moved server item type changed." } + } catch (failure: Throwable) { + restoreRemoteBackup(destinationPath, backupPath) + throw failure + } + deleteRemoteBackup(backupPath) + } + + private fun listDirectory(path: String): List { + var documents = rawListDirectory(path) + var recovered = false + documents.filter { desktopOwnedBackupDestination(it.entry.relativePath) != null }.forEach { backup -> + val destination = requireNotNull(desktopOwnedBackupDestination(backup.entry.relativePath)) + if (documents.none { it.entry.relativePath == destination }) { + moveRemoteDocument(backup, destination) + recovered = true + } + } + if (recovered) documents = rawListDirectory(path) + val listedPaths = documents.mapTo(hashSetOf()) { it.entry.relativePath } + return documents + .filterNot { isDesktopOwnedUploadStage(it.entry.relativePath) } + .filterNot { backup -> shouldSuppressDesktopOwnedBackup(backup.entry.relativePath, listedPaths) } + .also { require(it.size <= MAX_CHILDREN) { "A Nextcloud folder contains too many entries." } } + } + + private fun rawListDirectory(path: String): List { + val response = execute( + requestBuilder(fileUrl(path)) + .header("Accept", "application/xml") + .header("Depth", "1") + .method("PROPFIND", DIRECTORY_PROPERTIES.toRequestBody(XML_CONTENT_TYPE)) + .build(), + "list folder", + expectedStatus = 207, + maximumResponseBytes = MAX_DIRECTORY_RESPONSE_BYTES, + ) + val parent = path.trim('/') + return parseDesktopSyncDav(response, userId) + .filter { it.entry.relativePath.substringBeforeLast('/', "") == parent } + .also { require(it.size <= MAX_CHILDREN + MAX_RECOVERY_ITEMS) { "A Nextcloud folder contains too many entries." } } + } + + private fun DesktopRemoteSyncDocument.withPath(path: String): DesktopRemoteSyncDocument = + copy(entry = entry.copy(relativePath = path)) + + private fun replacementBackupPath(destinationPath: String): String { + val parent = destinationPath.substringBeforeLast('/', "") + val name = destinationPath.substringAfterLast('/') + return listOf(parent, ".$name$BACKUP_MARKER${UUID.randomUUID()}") + .filter(String::isNotBlank).joinToString("/") + } + + private fun moveRemoteDocument(source: DesktopRemoteSyncDocument, destinationPath: String) { + moveRemotePath( + sourcePath = source.entry.relativePath, + destinationPath = destinationPath, + sourceEtag = source.entry.etag, + sourceIsDirectory = source.isDirectory, + ) + } + + private fun moveRemotePath( + sourcePath: String, + destinationPath: String, + sourceEtag: String?, + sourceIsDirectory: Boolean, + ) { + val sourceUrl = fileUrl(sourcePath) + val builder = requestBuilder(sourceUrl) + .header("Destination", fileUrl(destinationPath)) + .header("Overwrite", "F") + if (sourceEtag != null) { + if (sourceIsDirectory) builder.header("If", "<$sourceUrl> ([${safeEtag(sourceEtag)}])") + else builder.header("If-Match", safeEtag(sourceEtag)) + } + execute(builder.method("MOVE", EMPTY_BODY).build(), "move item") + } + + private fun restoreRemoteBackup(destinationPath: String, backupPath: String) { + runCatching { + val documents = rawListDirectory(destinationPath.substringBeforeLast('/', "")) + if (documents.none { it.entry.relativePath == destinationPath }) { + documents.firstOrNull { it.entry.relativePath == backupPath } + ?.let { moveRemoteDocument(it, destinationPath) } + } + } + } + + private fun deleteRemoteBackup(backupPath: String) { + runCatching { + rawListDirectory(backupPath.substringBeforeLast('/', "")) + .firstOrNull { it.entry.relativePath == backupPath } + ?.let(::deleteRemoteDocument) + } + } + + private fun deleteRemoteStage(stagingPath: String, stagedEtag: String?) { + runCatching { + val builder = requestBuilder(fileUrl(stagingPath)) + stagedEtag?.let { builder.header("If-Match", safeEtag(it)) } + execute(builder.delete().build(), "remove staged upload") + } + } + + private fun deleteRemoteDocument(document: DesktopRemoteSyncDocument) { + val url = fileUrl(document.entry.relativePath) + val builder = requestBuilder(url) + if (document.isDirectory) builder.header("If", "<$url> ([${safeEtag(document.entry.etag)}])") + else builder.header("If-Match", safeEtag(document.entry.etag)) + execute(builder.delete().build(), "remove protected backup") + } + + private fun createFile(path: String, source: File): String? = executeForEtag( + requestBuilder(fileUrl(path)) + .header("If-None-Match", "*") + .put(source.asRequestBody(OCTET_STREAM)) + .build(), + "create file", + ) + + private fun replaceFileAtomically(path: String, source: File, expectedEtag: String) { + val parent = path.substringBeforeLast('/', "") + val stagingPath = listOf(parent, ".nextcloud-native-${UUID.randomUUID()}.upload") + .filter(String::isNotBlank).joinToString("/") + val stagingUrl = fileUrl(stagingPath) + val destinationUrl = fileUrl(path) + val stagedEtag = createFile(stagingPath, source) + try { + val builder = requestBuilder(stagingUrl) + .header("Destination", destinationUrl) + .header("Overwrite", "T") + .header("If", "<$destinationUrl> ([$expectedEtag])") + stagedEtag?.let { builder.header("If-Match", it) } + execute(builder.method("MOVE", EMPTY_BODY).build(), "replace file") + } catch (failure: Throwable) { + runCatching { + val cleanup = requestBuilder(stagingUrl) + stagedEtag?.let { cleanup.header("If-Match", it) } + execute(cleanup.delete().build(), "remove staged upload") + } + throw failure + } + } + + private fun executeForEtag(request: Request, operation: String): String? = + client.newCall(request).execute().use { response -> + require(response.code in 200..299) { response.failure(operation) } + response.header("ETag") ?: response.header("OC-Etag") + } + + private fun execute( + request: Request, + operation: String, + expectedStatus: Int? = null, + maximumResponseBytes: Long = MAX_ERROR_RESPONSE_BYTES, + ): ByteArray = client.newCall(request).execute().use { response -> + val accepted = expectedStatus?.let { response.code == it } ?: (response.code in 200..299) + require(accepted) { response.failure(operation) } + response.body.byteStream().readBounded(maximumResponseBytes) + } + + private fun requestBuilder(url: String): Request.Builder { + val authorization = Base64.getEncoder().encodeToString( + "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), + ) + return Request.Builder().url(url) + .header("Authorization", "Basic $authorization") + .header("User-Agent", USER_AGENT) + } + + private fun fileUrl(path: String): String = buildNextcloudFileUrl(session.serverUrl, userId, path) + + private fun fullPath(relativePath: String): String = + listOf(rootPath, relativePath.trim('/')).filter(String::isNotBlank).joinToString("/") + + private fun toRelativePath(fullPath: String): String? { + val normalized = fullPath.trim('/') + if (rootPath.isBlank()) return normalized.takeIf(String::isNotBlank) + return normalized.removePrefix("$rootPath/").takeIf { normalized.startsWith("$rootPath/") && it.isNotBlank() } + } + + private fun safeEtag(value: String): String = value.also { + require(it.isNotBlank() && '\r' !in it && '\n' !in it) { "The server revision is invalid." } + } + + private companion object { + const val MAX_ENTRIES = 20_000 + const val MAX_CHILDREN = 5_000 + const val MAX_RECOVERY_ITEMS = 32 + const val MAX_DEPTH = 64 + const val MAX_DIRECTORY_RESPONSE_BYTES = 16L * 1024L * 1024L + const val MAX_ERROR_RESPONSE_BYTES = 64L * 1024L + const val USER_AGENT = "Nextcloud-Native/0.1.0 (Desktop file sync)" + val XML_CONTENT_TYPE = "application/xml; charset=utf-8".toMediaType() + val OCTET_STREAM = "application/octet-stream".toMediaType() + val EMPTY_BODY = byteArrayOf().toRequestBody(null) + const val BACKUP_MARKER = ".nextcloud-native-backup-" + val DIRECTORY_PROPERTIES = """ + + + + + """.trimIndent() + } +} + +internal fun isDesktopOwnedUploadStage(relativePath: String): Boolean { + val name = relativePath.substringAfterLast('/') + if (!name.startsWith(".nextcloud-native-") || !name.endsWith(".upload")) return false + val token = name.removePrefix(".nextcloud-native-").removeSuffix(".upload") + return runCatching { UUID.fromString(token) }.isSuccess +} + +internal fun desktopOwnedBackupDestination(relativePath: String): String? { + val name = relativePath.substringAfterLast('/') + val markerIndex = name.lastIndexOf(".nextcloud-native-backup-") + if (!name.startsWith('.') || markerIndex <= 1) return null + val token = name.substring(markerIndex + ".nextcloud-native-backup-".length) + if (runCatching { UUID.fromString(token) }.isFailure) return null + val destinationName = name.substring(1, markerIndex) + if (destinationName.isBlank()) return null + val parent = relativePath.substringBeforeLast('/', "") + return listOf(parent, destinationName).filter(String::isNotBlank).joinToString("/") +} + +internal fun shouldSuppressDesktopOwnedBackup( + relativePath: String, + listedPaths: Set, +): Boolean { + val destination = desktopOwnedBackupDestination(relativePath) ?: return false + return destination !in listedPaths +} + +internal fun parseDesktopSyncDav(bytes: ByteArray, userId: String): List { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + } + val responses = factory.newDocumentBuilder().parse(ByteArrayInputStream(bytes)) + .getElementsByTagNameNS(DAV_NAMESPACE, "response") + return buildList { + for (index in 0 until responses.length) { + val response = responses.item(index) as? org.w3c.dom.Element ?: continue + val href = response.syncText("href") ?: continue + val decoded = URLDecoder.decode(href.replace("+", "%2B"), StandardCharsets.UTF_8) + val path = decoded.substringAfter("/files/$userId/", "").trim('/') + if (path.isBlank()) continue + val etag = response.syncText("getetag") ?: error("A server item has no usable revision.") + val isDirectory = response.getElementsByTagNameNS(DAV_NAMESPACE, "collection").length > 0 + add( + DesktopRemoteSyncDocument( + RemoteSyncEntry( + relativePath = path, + kind = if (isDirectory) SyncEntryKind.Directory else SyncEntryKind.File, + etag = etag, + size = response.syncText("getcontentlength")?.toLongOrNull() + ?.takeIf { !isDirectory }, + ), + isDirectory, + ), + ) + } + } +} + +private fun org.w3c.dom.Element.syncText(localName: String): String? = + getElementsByTagNameNS(DAV_NAMESPACE, localName).item(0)?.textContent?.takeIf(String::isNotBlank) + +private fun java.io.InputStream.readBounded(maximumBytes: Long): ByteArray { + val output = ByteArrayOutputStream() + copyBoundedTo(output, maximumBytes) + return output.toByteArray() +} + +private fun java.io.InputStream.copyBoundedTo(output: java.io.OutputStream, maximumBytes: Long) { + var total = 0L + val buffer = ByteArray(64 * 1024) + while (true) { + val count = read(buffer) + if (count < 0) break + total += count + require(total <= maximumBytes) { "The server response exceeds its safe size limit." } + output.write(buffer, 0, count) + } +} + +private fun okhttp3.Response.failure(operation: String): String = + "Could not $operation (HTTP $code)." + +private fun desktopFileSyncHttpClient(): OkHttpClient = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .build() + +private const val DAV_NAMESPACE = "DAV:" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditions.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditions.kt new file mode 100644 index 000000000..1b671c003 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditions.kt @@ -0,0 +1,150 @@ +package dev.obiente.nextcloudnative.app + +import com.sun.jna.Native +import com.sun.jna.Structure +import com.sun.jna.win32.StdCallLibrary +import java.io.File +import java.util.concurrent.TimeUnit + +internal data class DesktopFileSyncRuntimeConditions( + val unmeteredNetwork: Boolean?, + val hasBattery: Boolean, + val batteryPercent: Int?, + val externalPowerConnected: Boolean?, +) { + init { + require(batteryPercent == null || batteryPercent in 0..100) + } + + fun allows(configuration: FileSyncConfiguration): Boolean { + val networkAllowed = when (configuration.networkPolicy) { + FileSyncNetworkPolicy.AnyConnection -> true + FileSyncNetworkPolicy.Unmetered -> unmeteredNetwork == true + } + val powerAllowed = when (configuration.powerPolicy) { + FileSyncPowerPolicy.AnyPower -> true + FileSyncPowerPolicy.BatteryNotLow -> !hasBattery || + externalPowerConnected == true || + (batteryPercent ?: -1) >= BATTERY_LOW_PERCENT + FileSyncPowerPolicy.Charging -> !hasBattery || externalPowerConnected == true + } + return networkAllowed && powerAllowed + } + + private companion object { + const val BATTERY_LOW_PERCENT = 15 + } +} + +internal fun desktopFileSyncRuntimeConditions(): DesktopFileSyncRuntimeConditions = when { + isWindowsDesktop() -> windowsFileSyncRuntimeConditions() + System.getProperty("os.name").orEmpty().lowercase().contains("linux") -> + linuxFileSyncRuntimeConditions() + else -> DesktopFileSyncRuntimeConditions(null, hasBattery = false, null, null) +} + +private fun linuxFileSyncRuntimeConditions(): DesktopFileSyncRuntimeConditions { + val supplies = File("/sys/class/power_supply").listFiles().orEmpty() + val batteries = supplies.filter { it.resolve("type").readProbeText() == "Battery" } + val batteryPercent = batteries.mapNotNull { it.resolve("capacity").readProbeText()?.toIntOrNull() }.minOrNull() + val externalPower = supplies + .filter { it.resolve("type").readProbeText() in setOf("Mains", "USB", "USB_C", "Wireless") } + .mapNotNull { it.resolve("online").readProbeText()?.toIntOrNull() } + .takeIf(List::isNotEmpty) + ?.any { it == 1 } + return DesktopFileSyncRuntimeConditions( + unmeteredNetwork = parseNmcliMeteredProbe( + runDesktopProbe("nmcli", "-t", "-f", "GENERAL.STATE,GENERAL.METERED", "device", "show"), + ), + hasBattery = batteries.isNotEmpty(), + batteryPercent = batteryPercent, + externalPowerConnected = externalPower, + ) +} + +private fun windowsFileSyncRuntimeConditions(): DesktopFileSyncRuntimeConditions { + val power = runCatching { + DesktopSystemPowerStatus().also { status -> + val kernel = Native.load("kernel32", DesktopKernelPowerApi::class.java) + check(kernel.GetSystemPowerStatus(status) != 0) + status.read() + } + }.getOrNull() + val batteryFlag = power?.BatteryFlag?.toInt()?.and(0xff) + val hasBattery = batteryFlag != null && batteryFlag and 0x80 == 0 + val batteryPercent = power?.BatteryLifePercent?.toInt()?.and(0xff)?.takeIf { it <= 100 } + val externalPower = power?.ACLineStatus?.toInt()?.and(0xff)?.let { status -> + when (status) { + 0 -> false + 1 -> true + else -> null + } + } + val networkCost = runDesktopProbe( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + "[Windows.Networking.Connectivity.NetworkInformation,Windows.Networking.Connectivity,ContentType=WindowsRuntime]::GetInternetConnectionProfile().GetConnectionCost().NetworkCostType", + )?.trim() + return DesktopFileSyncRuntimeConditions( + unmeteredNetwork = when (networkCost) { + "Unrestricted" -> true + "Fixed", "Variable" -> false + else -> null + }, + hasBattery = hasBattery, + batteryPercent = batteryPercent, + externalPowerConnected = externalPower, + ) +} + +internal fun parseNmcliMeteredProbe(output: String?): Boolean? { + val connectedCosts = output.orEmpty().trim().split(Regex("\\n\\s*\\n")) + .mapNotNull { block -> + val fields = block.lineSequence().associate { line -> + line.substringBefore(':') to line.substringAfter(':', "") + } + val state = fields["GENERAL.STATE"]?.substringBefore(' ')?.toIntOrNull() + fields["GENERAL.METERED"]?.substringBefore(' ')?.takeIf { state == 100 } + } + if (connectedCosts.isEmpty()) return null + if (connectedCosts.any { it == "yes" || it == "guess-yes" }) return false + return true.takeIf { connectedCosts.all { it == "no" || it == "guess-no" } } +} + +private fun runDesktopProbe(vararg command: String): String? = runCatching { + val process = ProcessBuilder(*command).redirectErrorStream(true).start() + if (!process.waitFor(3, TimeUnit.SECONDS)) { + process.destroyForcibly() + return@runCatching null + } + process.inputStream.bufferedReader().use { it.readText().take(MAX_PROBE_OUTPUT_CHARS) } +}.getOrNull() + +private fun File.readProbeText(): String? = runCatching { + takeIf(File::isFile)?.readText()?.trim()?.take(MAX_PROBE_OUTPUT_CHARS) +}.getOrNull() + +private const val MAX_PROBE_OUTPUT_CHARS = 16_384 + +internal interface DesktopKernelPowerApi : StdCallLibrary { + fun GetSystemPowerStatus(status: DesktopSystemPowerStatus): Int +} + +@Structure.FieldOrder( + "ACLineStatus", + "BatteryFlag", + "BatteryLifePercent", + "SystemStatusFlag", + "BatteryLifeTime", + "BatteryFullLifeTime", +) +internal class DesktopSystemPowerStatus : Structure() { + @JvmField var ACLineStatus: Byte = 0 + @JvmField var BatteryFlag: Byte = 0 + @JvmField var BatteryLifePercent: Byte = 0 + @JvmField var SystemStatusFlag: Byte = 0 + @JvmField var BatteryLifeTime: Int = 0 + @JvmField var BatteryFullLifeTime: Int = 0 +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt index 64f5ecc49..109dd6065 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt @@ -2,10 +2,14 @@ package dev.obiente.nextcloudnative.app import java.io.File import java.io.FileOutputStream +import java.io.RandomAccessFile import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption import java.util.Base64 +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -34,6 +38,22 @@ internal data class DesktopFileSyncPersistedState( } internal class DesktopFileSyncStore(private val stateFile: File = desktopFileSyncStateFile()) { + private val transactionKey = runCatching(stateFile::getCanonicalPath).getOrElse { + stateFile.toPath().toAbsolutePath().normalize().toString() + } + + /** Serializes one complete load-mutate-save transaction across app processes. */ + fun withExclusiveAccess(block: () -> T): T = processLocks + .computeIfAbsent(transactionKey) { ReentrantLock() } + .withLock { + val parent = requireNotNull(stateFile.parentFile) + check(parent.isDirectory || parent.mkdirs()) { "Could not create desktop folder sync storage." } + val lockFile = File(parent, "${stateFile.name}.lock") + RandomAccessFile(lockFile, "rw").channel.use { channel -> + channel.lock().use { block() } + } + } + @Synchronized fun load(): DesktopFileSyncPersistedState { if (!stateFile.exists()) return DesktopFileSyncPersistedState() @@ -83,6 +103,10 @@ internal class DesktopFileSyncStore(private val stateFile: File = desktopFileSyn temporary.delete() } } + + private companion object { + val processLocks = ConcurrentHashMap() + } } @Serializable diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayPopup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayPopup.kt new file mode 100644 index 000000000..a6ed43d7e --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayPopup.kt @@ -0,0 +1,423 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudIcons +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun DesktopFileSyncTrayPopup( + snapshot: DesktopFileSyncTraySnapshot, + onOpenApp: () -> Unit, + onSyncNow: () -> Unit, + onTogglePaused: () -> Unit, + onQuit: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxSize() + .padding(10.dp) + .shadow(20.dp, RoundedCornerShape(24.dp)), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column { + TrayHeader(snapshot = snapshot, onOpenApp = onOpenApp) + snapshot.overallProgress?.let { progress -> + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth().height(3.dp), + ) + } + TrayQuickActions( + snapshot = snapshot, + onSyncNow = onSyncNow, + onTogglePaused = onTogglePaused, + onOpenApp = onOpenApp, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + TrayActivityList(snapshot = snapshot, onOpenApp = onOpenApp, modifier = Modifier.weight(1f)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onOpenApp) { Text("Open Nextcloud Native") } + TextButton(onClick = onQuit) { Text("Quit") } + } + } + } +} + +@Composable +private fun TrayHeader( + snapshot: DesktopFileSyncTraySnapshot, + onOpenApp: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + androidx.compose.foundation.Image( + painter = painterResource("nextcloud-native.png"), + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + snapshot.accountLabel ?: "Nextcloud Native", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(8.dp).clip(CircleShape).background(snapshot.statusColor()), + ) + Spacer(Modifier.width(7.dp)) + Text( + snapshot.compactStatus(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onOpenApp) { + Icon(NextcloudIcons.Settings, contentDescription = "Open settings") + } + } +} + +@Composable +private fun TrayQuickActions( + snapshot: DesktopFileSyncTraySnapshot, + onSyncNow: () -> Unit, + onTogglePaused: () -> Unit, + onOpenApp: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + TrayQuickAction( + label = "Sync now", + icon = NextcloudIcons.Refresh, + enabled = snapshot.phase != DesktopFileSyncTrayPhase.Syncing && + snapshot.phase != DesktopFileSyncTrayPhase.Paused, + onClick = onSyncNow, + modifier = Modifier.weight(1f), + ) + TrayQuickAction( + label = if (snapshot.phase == DesktopFileSyncTrayPhase.Paused) "Resume" else "Pause", + icon = if (snapshot.phase == DesktopFileSyncTrayPhase.Paused) { + NextcloudIcons.Play + } else { + NextcloudIcons.Pause + }, + enabled = true, + onClick = onTogglePaused, + modifier = Modifier.weight(1f), + ) + TrayQuickAction( + label = "Sync center", + icon = NextcloudIcons.FolderOpen, + enabled = true, + onClick = onOpenApp, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun TrayQuickAction( + label: String, + icon: ImageVector, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier, +) { + val contentColor = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + } + Surface( + modifier = modifier.clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = contentColor, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) + Text(label, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + } +} + +@Composable +private fun TrayActivityList( + snapshot: DesktopFileSyncTraySnapshot, + onOpenApp: () -> Unit, + modifier: Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 18.dp, end = 18.dp, top = 15.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("File activity", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + snapshot.lastCheckedEpochMillis?.let { checkedAt -> + Text( + "Checked ${formatTrayTime(checkedAt)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (snapshot.activities.isEmpty()) { + TrayEmptyActivity(snapshot) + } else { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + items(snapshot.activities, key = DesktopFileSyncTrayActivity::stableId) { activity -> + TrayActivityRow(activity, onOpenApp) + } + } + } + } +} + +@Composable +private fun TrayEmptyActivity(snapshot: DesktopFileSyncTraySnapshot) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 30.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + if (snapshot.pairCount == 0) NextcloudIcons.Folder else NextcloudIcons.CheckCircle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = if (snapshot.pairCount == 0) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + TraySuccessColor + }, + ) + Text( + if (snapshot.pairCount == 0) "No sync folders yet" else "Everything is up to date", + style = MaterialTheme.typography.titleSmall, + ) + Text( + if (snapshot.pairCount == 0) { + "Add a folder mapping in the sync center." + } else { + "New changes will appear here as they sync." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TrayActivityRow(activity: DesktopFileSyncTrayActivity, onOpenApp: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenApp) + .padding(horizontal = 18.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(38.dp).clip(RoundedCornerShape(12.dp)) + .background(activity.phase.containerColor()), + contentAlignment = Alignment.Center, + ) { + if (activity.phase.isInProgress()) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + activity.phase.icon(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = activity.phase.contentColor(), + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + activity.relativePath.substringAfterLast('/'), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + activity.pairLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + Text( + activity.phase.label(), + style = MaterialTheme.typography.labelSmall, + color = activity.phase.contentColor(), + ) + Text( + activity.detail ?: activity.sizeBytes?.let(::formatTrayBytes).orEmpty(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +@Composable +private fun DesktopFileSyncTraySnapshot.statusColor(): Color = when (phase) { + DesktopFileSyncTrayPhase.Idle -> TraySuccessColor + DesktopFileSyncTrayPhase.Syncing -> MaterialTheme.colorScheme.primary + DesktopFileSyncTrayPhase.Paused -> MaterialTheme.colorScheme.onSurfaceVariant + DesktopFileSyncTrayPhase.NeedsAttention -> MaterialTheme.colorScheme.error +} + +private fun DesktopFileSyncTraySnapshot.compactStatus(): String = when (phase) { + DesktopFileSyncTrayPhase.Syncing -> message ?: "Syncing changes" + DesktopFileSyncTrayPhase.Paused -> "Sync paused" + DesktopFileSyncTrayPhase.NeedsAttention -> when { + conflictCount > 0 && failedCount > 0 -> "$conflictCount conflict, $failedCount failed" + conflictCount > 0 -> "$conflictCount ${if (conflictCount == 1) "conflict" else "conflicts"}" + failedCount > 0 -> "$failedCount failed ${if (failedCount == 1) "item" else "items"}" + else -> "Sync needs attention" + } + DesktopFileSyncTrayPhase.Idle -> if (pairCount == 0) "Set up folder sync" else "Up to date" +} + +private fun DesktopFileSyncTrayActivityPhase.isInProgress(): Boolean = when (this) { + DesktopFileSyncTrayActivityPhase.Uploading, + DesktopFileSyncTrayActivityPhase.Downloading, + DesktopFileSyncTrayActivityPhase.Preparing, + -> true + else -> false +} + +private fun DesktopFileSyncTrayActivityPhase.icon(): ImageVector = when (this) { + DesktopFileSyncTrayActivityPhase.Conflict, + DesktopFileSyncTrayActivityPhase.Failed, + -> NextcloudIcons.Error + DesktopFileSyncTrayActivityPhase.Completed -> NextcloudIcons.CheckCircle + DesktopFileSyncTrayActivityPhase.Waiting -> NextcloudIcons.Schedule + DesktopFileSyncTrayActivityPhase.Uploading, + DesktopFileSyncTrayActivityPhase.Downloading, + DesktopFileSyncTrayActivityPhase.Preparing, + -> NextcloudIcons.Refresh +} + +@Composable +private fun DesktopFileSyncTrayActivityPhase.containerColor(): Color = when (this) { + DesktopFileSyncTrayActivityPhase.Conflict, + DesktopFileSyncTrayActivityPhase.Failed, + -> MaterialTheme.colorScheme.errorContainer + DesktopFileSyncTrayActivityPhase.Completed -> TraySuccessContainerColor + DesktopFileSyncTrayActivityPhase.Waiting -> MaterialTheme.colorScheme.surfaceContainerHighest + else -> MaterialTheme.colorScheme.primaryContainer +} + +@Composable +private fun DesktopFileSyncTrayActivityPhase.contentColor(): Color = when (this) { + DesktopFileSyncTrayActivityPhase.Conflict, + DesktopFileSyncTrayActivityPhase.Failed, + -> MaterialTheme.colorScheme.error + DesktopFileSyncTrayActivityPhase.Completed -> TraySuccessColor + DesktopFileSyncTrayActivityPhase.Waiting -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.primary +} + +private fun DesktopFileSyncTrayActivityPhase.label(): String = when (this) { + DesktopFileSyncTrayActivityPhase.Uploading -> "Uploading" + DesktopFileSyncTrayActivityPhase.Downloading -> "Downloading" + DesktopFileSyncTrayActivityPhase.Preparing -> "Applying" + DesktopFileSyncTrayActivityPhase.Waiting -> "Waiting" + DesktopFileSyncTrayActivityPhase.Conflict -> "Conflict" + DesktopFileSyncTrayActivityPhase.Failed -> "Failed" + DesktopFileSyncTrayActivityPhase.Completed -> "Synced" +} + +private fun formatTrayBytes(bytes: Long): String = when { + bytes >= 1_073_741_824L -> "%.1f GB".format(bytes / 1_073_741_824.0) + bytes >= 1_048_576L -> "%.1f MB".format(bytes / 1_048_576.0) + bytes >= 1_024L -> "%.1f KB".format(bytes / 1_024.0) + else -> "$bytes B" +} + +private fun formatTrayTime(epochMillis: Long): String = DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneId.systemDefault()) + .format(Instant.ofEpochMilli(epochMillis)) + +private val TraySuccessColor = Color(0xFF2E7D32) +private val TraySuccessContainerColor = Color(0xFFE6F4E7) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayState.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayState.kt new file mode 100644 index 000000000..e43fb802a --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayState.kt @@ -0,0 +1,122 @@ +package dev.obiente.nextcloudnative.app + +enum class DesktopFileSyncTrayPhase { + Idle, + Syncing, + Paused, + NeedsAttention, +} + +enum class DesktopFileSyncTrayActivityPhase { + Uploading, + Downloading, + Preparing, + Waiting, + Conflict, + Failed, + Completed, +} + +data class DesktopFileSyncTrayActivity( + val stableId: String, + val relativePath: String, + val pairLabel: String, + val phase: DesktopFileSyncTrayActivityPhase, + val sizeBytes: Long? = null, + val detail: String? = null, +) { + init { + require(stableId.isNotBlank() && stableId.length <= 320 && stableId.none(Char::isISOControl)) + require(relativePath.isNotBlank() && relativePath.length <= 4_096 && relativePath.none(Char::isISOControl)) + require(pairLabel.isNotBlank() && pairLabel.length <= 512 && pairLabel.none(Char::isISOControl)) + require(sizeBytes == null || sizeBytes >= 0L) + require(detail == null || detail.isNotBlank() && detail.length <= 1_024 && detail.none(Char::isISOControl)) + } +} + +enum class DesktopFileSyncProgressStage { + Started, + Completed, + Failed, +} + +data class DesktopFileSyncProgressEvent( + val pairId: String, + val workId: Long, + val relativePath: String, + val pairLabel: String, + val operation: FileSyncOperation, + val completedOperations: Int, + val totalOperations: Int, + val sizeBytes: Long?, + val stage: DesktopFileSyncProgressStage, + val failureMessage: String? = null, +) { + init { + require(pairId.isNotBlank()) + require(workId > 0L) + requireValidSyncPath(relativePath) + require(pairLabel.isNotBlank()) + require(completedOperations in 0..totalOperations) + require(totalOperations > 0) + require(sizeBytes == null || sizeBytes >= 0L) + require((stage == DesktopFileSyncProgressStage.Failed) == (failureMessage != null)) + require(failureMessage == null || failureMessage.isNotBlank()) + } + + val stableId: String = "$pairId:$workId" + val progressFraction: Float = completedOperations.toFloat() / totalOperations.toFloat() +} + +data class DesktopFileSyncTraySnapshot( + val phase: DesktopFileSyncTrayPhase, + val pairCount: Int = 0, + val pendingCount: Int = 0, + val conflictCount: Int = 0, + val failedCount: Int = 0, + val message: String? = null, + val accountLabel: String? = null, + val overallProgress: Float? = null, + val activities: List = emptyList(), + val lastCheckedEpochMillis: Long? = null, +) { + init { + require(listOf(pairCount, pendingCount, conflictCount, failedCount).all { it >= 0 }) + require(message == null || message.isNotBlank()) + require(accountLabel == null || accountLabel.isNotBlank() && accountLabel.none(Char::isISOControl)) + require(overallProgress == null || overallProgress in 0f..1f) + require(activities.size <= MAX_TRAY_ACTIVITY_ITEMS) + require(activities.map(DesktopFileSyncTrayActivity::stableId).distinct().size == activities.size) + require(lastCheckedEpochMillis == null || lastCheckedEpochMillis >= 0L) + } +} + +internal const val MAX_TRAY_ACTIVITY_ITEMS = 8 + +internal fun FileSyncOperation.toTrayActivityPhase(): DesktopFileSyncTrayActivityPhase = when (this) { + is FileSyncOperation.Upload -> DesktopFileSyncTrayActivityPhase.Uploading + is FileSyncOperation.Download -> DesktopFileSyncTrayActivityPhase.Downloading + is FileSyncOperation.DeleteLocal, + is FileSyncOperation.DeleteRemote, + is FileSyncOperation.KeepBoth, + -> DesktopFileSyncTrayActivityPhase.Preparing + is FileSyncOperation.NeedsDecision -> DesktopFileSyncTrayActivityPhase.Conflict + is FileSyncOperation.Skipped -> DesktopFileSyncTrayActivityPhase.Waiting +} + +fun DesktopFileSyncTraySnapshot.tooltip(): String = when (phase) { + DesktopFileSyncTrayPhase.Syncing -> "Nextcloud Native - syncing" + DesktopFileSyncTrayPhase.Paused -> "Nextcloud Native - sync paused" + DesktopFileSyncTrayPhase.NeedsAttention -> buildString { + append("Nextcloud Native - attention needed") + if (conflictCount > 0) { + append("; ").append(conflictCount).append(if (conflictCount == 1) " conflict" else " conflicts") + } + if (failedCount > 0) append("; ").append(failedCount).append(" failed") + } + DesktopFileSyncTrayPhase.Idle -> when { + pairCount == 0 -> "Nextcloud Native - no sync folders" + pendingCount > 0 -> "Nextcloud Native - $pendingCount pending" + else -> "Nextcloud Native - up to date" + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt new file mode 100644 index 000000000..1693da29c --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt @@ -0,0 +1,374 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +internal data class DesktopLinuxPendingWriteback( + val path: String, + val expectedRemoteRevision: String?, + val stagedBytes: Long, + val stagedAtEpochMillis: Long, + val dirty: Boolean, +) + +internal data class DesktopLinuxWritebackRecoveryResult( + val recoveredCount: Int, + val retainedCount: Int, +) + +internal interface LinuxVirtualWritebackRemote { + fun resolveFile(relativePath: String): RemoteSyncEntry? + + fun stageDownload( + relativePath: String, + expectedRemoteEtag: String, + destination: File, + maximumBytes: Long, + ): RemoteSyncEntry + + fun writeFile(relativePath: String, source: File, expectedRemoteEtag: String?): RemoteSyncEntry +} + +/** Durable local staging for editable Linux virtual files. */ +internal class DesktopLinuxVirtualFileWritebackStore( + private val root: File, + private val minimumFreeSpaceBytes: () -> Long = { DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES }, + private val afterDirtyIntentPersisted: () -> Unit = {}, +) { + @Synchronized + fun open( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + tree: LinuxVirtualWritebackRemote, + onCommitted: (String) -> Unit, + ): LinuxVirtualFileWriteHandle { + require(path.isNotBlank()) + require(existing == null || !existing.directory) + require(existing == null || existing.path == path) + val directory = root.apply { + check(isDirectory || mkdirs()) { "Could not create Linux virtual-file recovery storage." } + } + if (existing != null && !truncate) { + require(existing.size <= MAX_WRITEBACK_BYTES) { + "The Linux virtual file is too large for editable staging." + } + require( + linuxWritebackFitsCapacity( + remoteBytes = existing.size, + availableBytes = directory.usableSpace.coerceAtLeast(0L), + reserveBytes = minimumFreeSpaceBytes(), + ), + ) { "There is not enough free space to stage this Linux virtual-file edit safely." } + } + val stage = File.createTempFile("writeback-", ".stage", directory) + val manifestFile = File(directory, stage.name + ".json") + val stagedAt = System.currentTimeMillis() + var expectedRevision = existing?.remoteRevision + var dirty = existing == null || truncate + try { + if (existing != null && !truncate) { + tree.stageDownload( + relativePath = path, + expectedRemoteEtag = existing.remoteRevision, + destination = stage, + maximumBytes = MAX_WRITEBACK_BYTES, + ) + } + val random = RandomAccessFile(stage, "rw") + try { + saveManifest( + manifestFile, + WritebackManifest(path, expectedRevision, stagedAt, dirty, stage.name), + ) + if (truncate) { + afterDirtyIntentPersisted() + random.setLength(0L) + } + } catch (failure: Throwable) { + runCatching(random::close) + throw failure + } + return object : LinuxVirtualFileWriteHandle { + private var closed = false + + override val size: Long + @Synchronized get() = random.length() + + @Synchronized + override fun read(offset: Long, length: Int): ByteArray { + check(!closed) + require(offset >= 0L && length > 0 && offset + length <= random.length()) + return ByteArray(length).also { bytes -> + random.seek(offset) + random.readFully(bytes) + } + } + + @Synchronized + override fun write(offset: Long, bytes: ByteArray): Int { + check(!closed) + require(offset >= 0L && bytes.isNotEmpty()) + val end = runCatching { Math.addExact(offset, bytes.size.toLong()) } + .getOrElse { throw IllegalArgumentException("The Linux virtual file exceeds the writeback limit.") } + require(end <= MAX_WRITEBACK_BYTES) + requireGrowthCapacity(end) + markDirtyBeforeMutation() + requireGrowthCapacity(end) + random.seek(offset) + random.write(bytes) + return bytes.size + } + + @Synchronized + override fun truncate(size: Long) { + check(!closed) + require(size in 0L..MAX_WRITEBACK_BYTES) + requireGrowthCapacity(size) + markDirtyBeforeMutation() + requireGrowthCapacity(size) + random.setLength(size) + } + + private fun requireGrowthCapacity(targetBytes: Long) { + require( + linuxWritebackGrowthFitsCapacity( + currentBytes = random.length(), + targetBytes = targetBytes, + availableBytes = directory.usableSpace.coerceAtLeast(0L), + reserveBytes = minimumFreeSpaceBytes(), + ), + ) { "There is not enough free space to grow this Linux virtual-file edit safely." } + } + + private fun markDirtyBeforeMutation() { + if (dirty) return + saveManifest( + manifestFile, + WritebackManifest(path, expectedRevision, stagedAt, true, stage.name), + ) + dirty = true + afterDirtyIntentPersisted() + } + + @Synchronized + override fun flush() { + check(!closed) + if (!dirty) return + random.fd.sync() + val uploaded = tree.writeFile(path, stage, expectedRevision) + expectedRevision = uploaded.etag + dirty = false + saveManifest( + manifestFile, + WritebackManifest(path, expectedRevision, stagedAt, false, stage.name), + ) + onCommitted(path) + } + + @Synchronized + override fun close() { + if (closed) return + var failure: Throwable? = null + if (dirty) runCatching(::flush).onFailure { failure = it } + closed = true + runCatching(random::close) + if (!dirty && failure == null) { + manifestFile.delete() + stage.delete() + } + failure?.let { throw it } + } + } + } catch (failure: Throwable) { + if (!manifestFile.exists()) stage.delete() + throw failure + } + } + + @Synchronized + fun pendingWritebacks(): List { + if (!root.isDirectory) return emptyList() + return root.listFiles().orEmpty().filter { it.isFile && it.name.endsWith(".stage.json") } + .mapNotNull { manifestFile -> + val manifest = runCatching { + writebackJson.decodeFromString(manifestFile.readText()).also { it.requireValid() } + }.getOrNull() ?: return@mapNotNull null + val stage = File(root, manifest.stageName) + if (!stage.isFile) return@mapNotNull null + DesktopLinuxPendingWriteback( + path = manifest.path, + expectedRemoteRevision = manifest.expectedRemoteRevision, + stagedBytes = stage.length(), + stagedAtEpochMillis = manifest.stagedAtEpochMillis, + dirty = manifest.dirty, + ) + }.sortedBy(DesktopLinuxPendingWriteback::stagedAtEpochMillis) + } + + @Synchronized + fun recoverPending( + tree: LinuxVirtualWritebackRemote, + onCommitted: (String) -> Unit, + ): DesktopLinuxWritebackRecoveryResult { + var recovered = 0 + var retained = 0 + pendingWritebacks().forEach { pending -> + val manifestFile = root.listFiles().orEmpty().firstOrNull { candidate -> + if (!candidate.name.endsWith(".stage.json")) return@firstOrNull false + runCatching { + val manifest = writebackJson.decodeFromString(candidate.readText()) + manifest.path == pending.path && manifest.stagedAtEpochMillis == pending.stagedAtEpochMillis + }.getOrDefault(false) + } ?: return@forEach + val manifest = runCatching { + writebackJson.decodeFromString(manifestFile.readText()).also { it.requireValid() } + }.getOrNull() ?: return@forEach + val stage = File(root, manifest.stageName) + if (!stage.isFile) return@forEach + if (!manifest.dirty) { + manifestFile.delete() + stage.delete() + recovered += 1 + return@forEach + } + val recoveredRemote = matchingRemoteGeneration(tree, manifest.path, stage) + if (recoveredRemote != null) { + onCommitted(manifest.path) + manifestFile.delete() + stage.delete() + recovered += 1 + return@forEach + } + runCatching { tree.writeFile(manifest.path, stage, manifest.expectedRemoteRevision) } + .recoverCatching { failure -> + matchingRemoteGeneration(tree, manifest.path, stage) ?: throw failure + } + .onSuccess { + onCommitted(manifest.path) + manifestFile.delete() + stage.delete() + recovered += 1 + } + .onFailure { retained += 1 } + } + return DesktopLinuxWritebackRecoveryResult(recovered, retained) + } + + private fun matchingRemoteGeneration( + tree: LinuxVirtualWritebackRemote, + path: String, + stage: File, + ): RemoteSyncEntry? { + val remote = tree.resolveFile(path) + ?.takeIf { it.kind == SyncEntryKind.File && it.size == stage.length() } + ?: return null + val downloaded = File.createTempFile("reconcile-", ".stage", root) + return try { + val verified = tree.stageDownload(path, remote.etag, downloaded, MAX_WRITEBACK_BYTES) + verified.takeIf { + it.etag == remote.etag && + downloaded.length() == stage.length() && + Files.mismatch(downloaded.toPath(), stage.toPath()) == -1L + } + } finally { + downloaded.delete() + } + } + + private fun saveManifest(destination: File, manifest: WritebackManifest) { + manifest.requireValid() + val bytes = writebackJson.encodeToString(manifest).encodeToByteArray() + require(bytes.size <= MAX_MANIFEST_BYTES) + val temporary = File.createTempFile("manifest-", ".tmp", root) + try { + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + if (System.getProperty("os.name").equals("Linux", ignoreCase = true)) { + FileChannel.open(root.toPath(), StandardOpenOption.READ).use { directory -> + directory.force(true) + } + } + } finally { + temporary.delete() + } + } + + private companion object { + const val MAX_WRITEBACK_BYTES = 256L * 1024L * 1024L * 1024L + const val MAX_MANIFEST_BYTES = 64 * 1024 + } +} + +internal fun linuxWritebackFitsCapacity( + remoteBytes: Long, + availableBytes: Long, + reserveBytes: Long = DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES, +): Boolean = + remoteBytes >= 0L && + availableBytes >= 0L && + reserveBytes >= 0L && + availableBytes >= remoteBytes && + availableBytes - remoteBytes >= reserveBytes + +internal fun linuxWritebackGrowthFitsCapacity( + currentBytes: Long, + targetBytes: Long, + availableBytes: Long, + reserveBytes: Long = DEFAULT_VIRTUAL_FILE_MINIMUM_FREE_BYTES, +): Boolean { + if (currentBytes < 0L || targetBytes < 0L || availableBytes < 0L || reserveBytes < 0L) return false + val growth = (targetBytes - currentBytes).coerceAtLeast(0L) + return availableBytes >= growth && availableBytes - growth >= reserveBytes +} + +internal fun defaultDesktopLinuxWritebackStore(session: NextcloudSession): DesktopLinuxVirtualFileWritebackStore { + val xdgData = System.getenv("XDG_DATA_HOME")?.takeIf(String::isNotBlank) + val dataRoot = xdgData?.let(::File) ?: File(System.getProperty("user.home"), ".local/share") + return DesktopLinuxVirtualFileWritebackStore( + File(dataRoot, "nextcloud-native/vfs-writeback/${desktopFileCacheAccountId(session)}"), + ) +} + +@Serializable +private data class WritebackManifest( + val path: String, + val expectedRemoteRevision: String?, + val stagedAtEpochMillis: Long, + val dirty: Boolean, + val stageName: String, +) { + fun requireValid() { + FileOfflineKey("account", path) + require(expectedRemoteRevision == null || expectedRemoteRevision.isNotBlank()) + require(stagedAtEpochMillis >= 0L) + require(stageName.startsWith("writeback-") && stageName.endsWith(".stage")) + require('/' !in stageName && '\\' !in stageName) + } +} + +private val writebackJson = Json { + encodeDefaults = true + ignoreUnknownKeys = false + explicitNulls = false +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 90ee1107a..2baf7a7c8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -27,9 +27,22 @@ import java.util.UUID import java.util.concurrent.TimeUnit import java.util.prefs.Preferences import javax.xml.parsers.DocumentBuilderFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType @@ -113,10 +126,32 @@ internal fun resolveDesktopNextcloudRedirectLocation( internal const val DIRECT_EDITING_OPEN_RELATIVE_PATH = "/ocs/v2.php/apps/files/api/v1/directEditing/open?format=json" +private enum class DesktopFileSyncRunSource { + Background, + Resume, + Tray, +} + private const val MAX_DOCUMENT_TEMPLATE_ID_LENGTH = 256 private const val MAX_DOCUMENT_TEMPLATE_NAME_LENGTH = 512 private const val MAX_DOCUMENT_TEMPLATE_EXTENSION_LENGTH = 32 +private fun isLinuxDesktop(): Boolean = + System.getProperty("os.name").orEmpty().lowercase().contains("linux") + +private fun desktopLinuxVirtualFileMountPoint(): File = + File(System.getProperty("user.home"), "Nextcloud Native") + +private fun desktopWindowsCloudFilesRoot(accountId: String): File { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return File(File(System.getProperty("user.home"), "Nextcloud Native"), accountId) +} + +private fun virtualFileProviderPreferenceKey(accountId: String): String { + require(accountId.isNotBlank() && accountId.length <= 128) + return "virtual-file-provider-active.$accountId" +} + internal fun documentTemplatesRelativePath(editorId: String, creatorId: String): String { require(editorId.isSafeDocumentCapabilityId()) { "The document editor ID is invalid." } require(creatorId.isSafeDocumentCapabilityId()) { "The document creator ID is invalid." } @@ -361,9 +396,29 @@ internal suspend fun executeDesktopDynamicApiGet( ) } +internal fun combinedAutomaticCacheExcess( + maximumBytes: Long, + completeFileBytes: Long, + rangeBytes: Long, + windowsCachedBytes: Long, + windowsPinnedBytes: Long, +): Long { + require(maximumBytes > 0L) + require(listOf(completeFileBytes, rangeBytes, windowsCachedBytes, windowsPinnedBytes).all { it >= 0L }) + require(windowsPinnedBytes <= windowsCachedBytes) + val total = listOf( + completeFileBytes, + rangeBytes, + windowsCachedBytes - windowsPinnedBytes, + ).fold(0L) { accumulated, bytes -> + if (bytes > Long.MAX_VALUE - accumulated) Long.MAX_VALUE else accumulated + bytes + } + return (total - maximumBytes).coerceAtLeast(0L) +} + class DesktopNextcloudServices( private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, -) : NextcloudPlatformServices { +) : NextcloudPlatformServices, AutoCloseable { private val preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative") private val appUpdater = DesktopAppUpdater(preferences.node("app-updates-v1")) private val httpClient = OkHttpClient() @@ -377,6 +432,14 @@ class DesktopNextcloudServices( verifiedContractCache = FileVerifiedContractCache(desktopContractCacheDirectory("verified")), ) private val fileReadCache = defaultDesktopFileReadCache() + private val virtualRangeCache = defaultDesktopVirtualRangeCache(fileReadCache::loadPolicy) + private val virtualFileProviderLock = Any() + private var linuxVirtualFileSystem: LinuxNextcloudVirtualFileSystem? = null + private var linuxVirtualFileMountIdentity: String? = null + private var linuxVirtualFileFailure: String? = null + private var windowsCloudFilesProvider: WindowsCloudFilesProvider? = null + private var windowsCloudFilesIdentity: String? = null + private var windowsCloudFilesFailure: String? = null private val dynamicApiReadCache = DynamicApiResponseCache( desktopContractCacheDirectory("responses"), ) @@ -388,12 +451,56 @@ class DesktopNextcloudServices( private val externalFileHandoff = DesktopExternalFileHandoff() private val localUploadPicker = DesktopLocalUploadPicker() private val deckCardDrafts = DesktopDeckCardDraftStore() + private val fileSyncEngine = DesktopFileSyncEngine( + minimumFreeSpaceBytes = { fileReadCache.loadPolicy().minimumFreeSpaceBytes }, + ) + private val startOnLoginController = DesktopStartOnLoginController() + private val fileSyncRunLock = Mutex() + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var backgroundFileSyncJob: Job? = null + private val mutableFileSyncTraySnapshot = MutableStateFlow( + DesktopFileSyncTraySnapshot( + phase = if (preferences.getBoolean(KEY_FILE_SYNC_PAUSED, false)) { + DesktopFileSyncTrayPhase.Paused + } else { + DesktopFileSyncTrayPhase.Idle + }, + ), + ) + val fileSyncTraySnapshot: StateFlow = + mutableFileSyncTraySnapshot.asStateFlow() private val projectNewsCache = File( desktopContractCacheDirectory("responses").parentFile, "project-content/news-feed-v1.json", ) private val projectNewsImageDirectory = File(projectNewsCache.parentFile, "news-images") + suspend fun restoreVirtualFileProviderIfEnabled() { + val session = loadSession() ?: return + val accountId = desktopFileCacheAccountId(session) + if (!preferences.getBoolean(virtualFileProviderPreferenceKey(accountId), false)) return + val userId = loadServerInfo(session).userId + loadVirtualFileStorage(session, userId) + } + + fun startDesktopSyncLifecycle() { + synchronized(this) { + if (backgroundFileSyncJob?.isActive == true) return + backgroundFileSyncJob = serviceScope.launch { + if (loadStartOnLoginPreference()) { + runCatching { startOnLoginController.configure(enabled = true) } + } + while (isActive) { + if (!isFileSyncPaused()) { + runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } + .onFailure(::publishBackgroundFileSyncFailure) + } + delay(DESKTOP_FILE_SYNC_INTERVAL_MILLIS) + } + } + } + } + override val externalFileHandoffSupport: ExternalFileHandoffSupport = ExternalFileHandoffSupport.Available( ExternalFileHandoffCapability( supportedActions = setOf(ExternalFileHandoffAction.OpenWith), @@ -401,6 +508,628 @@ class DesktopNextcloudServices( ), ) + override val supportsBidirectionalFileSync: Boolean = true + override val supportsVirtualFileStorage: Boolean = true + + override suspend fun loadVirtualFileStorage( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageSnapshot = withContext(Dispatchers.IO) { + val accountId = desktopFileCacheAccountId(session) + val providerPreferenceKey = virtualFileProviderPreferenceKey(accountId) + if ( + (isLinuxDesktop() || isWindowsDesktop()) && + preferences.getBoolean(providerPreferenceKey, false) && + synchronized(virtualFileProviderLock) { + linuxVirtualFileMountIdentity != accountId && windowsCloudFilesIdentity != accountId + } + ) { + runCatching { activateVirtualFileProvider(session, userId) } + } + enforceCombinedVirtualFileCachePolicy(accountId, fileReadCache.loadPolicy()) + val cache = fileReadCache.virtualFileSummary(accountId) + val ranges = virtualRangeCache.summary(accountId) + val linux = isLinuxDesktop() + val windows = isWindowsDesktop() + val active = synchronized(virtualFileProviderLock) { + (linux && linuxVirtualFileSystem != null && linuxVirtualFileMountIdentity == accountId) || + (windows && windowsCloudFilesProvider != null && windowsCloudFilesIdentity == accountId) + } + val windowsSummary = windowsVirtualFileSummary(accountId) + val writebacks = defaultDesktopLinuxWritebackStore(session).pendingWritebacks() + VirtualFileStorageSnapshot( + support = if (linux || windows) VirtualFileStorageSupport.Available else VirtualFileStorageSupport.CacheOnly, + integration = when { + linux -> VirtualFilePlatformIntegration.LinuxFilesystemMount + windows -> VirtualFilePlatformIntegration.WindowsCloudFiles + else -> VirtualFilePlatformIntegration.InAppOnDemandCache + }, + policy = cache.policy, + cachedBytes = cache.cachedBytes + ranges.cachedBytes + (windowsSummary?.cachedBytes ?: 0L), + reclaimableBytes = cache.reclaimableBytes + ranges.reclaimableBytes + + (windowsSummary?.reclaimableBytes ?: 0L), + pinnedBytes = windowsSummary?.pinnedBytes ?: 0L, + hydratedFileCount = cache.entryCount + ranges.fileCount + + (windowsSummary?.hydratedFileCount ?: 0), + pinnedFileCount = windowsSummary?.pinnedFileCount ?: 0, + availableFreeBytes = listOfNotNull( + cache.availableFreeBytes, + ranges.availableFreeBytes, + windowsSummary?.availableFreeBytes, + ).minOrNull(), + storageCapacityBytes = null, + limitations = buildList { + add("Range blocks and complete files share the managed automatic-cleanup policy.") + linuxVirtualFileFailure?.let { add("The last Linux mount attempt failed: $it") } + windowsCloudFilesFailure?.let { add("The last Windows Cloud Files activation failed: $it") } + if (windows) { + add("Windows can dehydrate in-sync placeholders automatically when space is needed.") + } + if (writebacks.isNotEmpty()) { + add("${writebacks.size} staged writeback(s) need recovery before local edits can be discarded.") + } + if ((windowsSummary?.pendingWritebackCount ?: 0) > 0) { + add("${windowsSummary?.pendingWritebackCount} Windows edit(s) are waiting for conflict-safe writeback.") + } + if ((windowsSummary?.failedWritebackCount ?: 0) > 0) { + add("${windowsSummary?.failedWritebackCount} Windows edit(s) need attention after bounded retries.") + } + }, + providerState = when { + (windowsSummary?.failedWritebackCount ?: 0) > 0 -> VirtualFileProviderState.NeedsAttention + active -> VirtualFileProviderState.Active + linuxVirtualFileFailure != null || windowsCloudFilesFailure != null -> + VirtualFileProviderState.NeedsAttention + linux || windows -> VirtualFileProviderState.Inactive + else -> VirtualFileProviderState.NotApplicable + }, + providerLocation = when { + linux -> desktopLinuxVirtualFileMountPoint().absolutePath + windows -> desktopWindowsCloudFilesRoot(accountId).absolutePath + else -> null + }, + pendingWritebackCount = writebacks.size + (windowsSummary?.pendingWritebackCount ?: 0), + ) + } + + override suspend fun saveVirtualFileCachePolicy( + session: NextcloudSession, + userId: String, + policy: VirtualFileCachePolicy, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + fileReadCache.savePolicy(policy) + enforceCombinedVirtualFileCachePolicy(desktopFileCacheAccountId(session), policy) + VirtualFileStorageActionResult.Completed("Virtual file storage rules saved.") + } + + override suspend fun freeUpVirtualFileSpace( + session: NextcloudSession, + userId: String, + requestedBytes: Long, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + require(requestedBytes >= 0L) + val accountId = desktopFileCacheAccountId(session) + val before = fileReadCache.virtualFileSummary(accountId).cachedBytes + + virtualRangeCache.summary(accountId).cachedBytes + val windowsFreed = synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId } + ?.freeUpSpace(requestedBytes) + } ?: 0L + val rangePlan = virtualRangeCache.freeUp(accountId, (requestedBytes - windowsFreed).coerceAtLeast(0L)) + val remaining = (requestedBytes - windowsFreed - rangePlan.plannedFreedBytes).coerceAtLeast(0L) + fileReadCache.freeUpVirtualFiles(accountId, remaining) + val after = fileReadCache.virtualFileSummary(accountId).cachedBytes + + virtualRangeCache.summary(accountId).cachedBytes + val freed = windowsFreed + (before - after).coerceAtLeast(0L) + VirtualFileStorageActionResult.Completed( + message = if (freed > 0L) { + "Freed ${formatVirtualFileBytes(freed)} of disposable virtual file content." + } else { + "No disposable virtual file content could be freed. Active files were kept." + }, + freedBytes = freed, + ) + } + + override suspend fun activateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + if (!isLinuxDesktop() && !isWindowsDesktop()) { + return@withContext VirtualFileStorageActionResult.Unsupported( + "This desktop build does not have a system virtual-file adapter for the current operating system.", + ) + } + val accountId = desktopFileCacheAccountId(session) + synchronized(virtualFileProviderLock) { + if (isWindowsDesktop()) { + if (windowsCloudFilesProvider != null && windowsCloudFilesIdentity == accountId) { + return@withContext VirtualFileStorageActionResult.Completed( + "Windows Cloud Files are already connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", + ) + } + windowsCloudFilesProvider?.close() + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + val root = desktopWindowsCloudFilesRoot(accountId).toPath() + val provider = WindowsCloudFilesProvider( + root = root, + backend = DesktopNextcloudWindowsCloudFilesBackend( + session = session, + userId = userId, + services = this@DesktopNextcloudServices, + ), + api = JnaWindowsCloudFilesApi(), + ) + try { + provider.start() + windowsCloudFilesProvider = provider + windowsCloudFilesIdentity = accountId + windowsCloudFilesFailure = null + preferences.putBoolean(virtualFileProviderPreferenceKey(accountId), true) + } catch (failure: Throwable) { + runCatching(provider::close) + windowsCloudFilesFailure = failure.message ?: "Unknown Cloud Files activation failure" + throw failure + } + return@withContext VirtualFileStorageActionResult.Completed( + "Windows Cloud Files connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", + ) + } + if (linuxVirtualFileSystem != null && linuxVirtualFileMountIdentity == accountId) { + return@withContext VirtualFileStorageActionResult.Completed( + "Virtual files are already mounted at ${desktopLinuxVirtualFileMountPoint().absolutePath}.", + ) + } + if (linuxVirtualFileSystem != null) { + runCatching { linuxVirtualFileSystem?.unmount() } + linuxVirtualFileSystem = null + linuxVirtualFileMountIdentity = null + } + val mountPoint = desktopLinuxVirtualFileMountPoint().apply { + check(isDirectory || mkdirs()) { "Could not create the virtual-files mount folder." } + } + check(!Files.isSymbolicLink(mountPoint.toPath())) { "The virtual-files mount folder cannot be a symlink." } + check(mountPoint.list().orEmpty().isEmpty()) { + "The virtual-files mount folder must be empty before it can be activated." + } + val writebackStore = defaultDesktopLinuxWritebackStore(session) + writebackStore.recoverPending( + tree = DesktopFileSyncRemoteTree(session, userId, ""), + onCommitted = { path -> virtualRangeCache.invalidate(accountId, path) }, + ) + val fileSystem = LinuxNextcloudVirtualFileSystem( + DesktopNextcloudVirtualFileBackend( + session = session, + userId = userId, + services = this@DesktopNextcloudServices, + rangeCache = virtualRangeCache, + writebacks = writebackStore, + ), + ) + try { + fileSystem.mountAt(mountPoint.toPath()) + linuxVirtualFileSystem = fileSystem + linuxVirtualFileMountIdentity = accountId + linuxVirtualFileFailure = null + preferences.putBoolean(virtualFileProviderPreferenceKey(accountId), true) + } catch (failure: Throwable) { + linuxVirtualFileFailure = failure.message ?: "Unknown FUSE mount failure" + throw failure + } + } + VirtualFileStorageActionResult.Completed( + "Virtual files mounted at ${desktopLinuxVirtualFileMountPoint().absolutePath}.", + ) + } + + override suspend fun deactivateVirtualFileProvider( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + windowsCloudFilesProvider?.close() + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + windowsCloudFilesFailure = null + preferences.putBoolean( + virtualFileProviderPreferenceKey(desktopFileCacheAccountId(session)), + false, + ) + } + VirtualFileStorageActionResult.Completed( + if (isWindowsDesktop()) { + "Windows Cloud Files disconnected. Placeholders, cached content, and remote files were kept." + } else { + "Virtual files unmounted. Cached content and remote files were kept." + }, + ) + } + + override fun close() { + serviceScope.cancel() + synchronized(virtualFileProviderLock) { + runCatching { linuxVirtualFileSystem?.unmount() } + linuxVirtualFileSystem = null + linuxVirtualFileMountIdentity = null + runCatching { windowsCloudFilesProvider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + } + } + + override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = + fileSyncEngine.chooseLocalRoot(initialRootHint) + + override suspend fun loadFileSyncCenter( + session: NextcloudSession, + userId: String, + ): FileSyncCenterSnapshot = withContext(Dispatchers.IO) { + val center = fileSyncEngine.loadCenter(session) + publishFileSyncTraySnapshot(center, fileSyncEngine.loadTrayActivities(session)) + center + } + + override suspend fun addFileSyncPair( + session: NextcloudSession, + userId: String, + localRoot: FileSyncLocalRoot, + remoteRootPath: String, + configuration: FileSyncConfiguration, + ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { + fileSyncEngine.addPair(session, localRoot, remoteRootPath, configuration).also { + runCatching { + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + } + } + + override suspend fun runFileSyncPair( + session: NextcloudSession, + userId: String, + pairId: String, + ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { + fileSyncRunLock.withLock { + if (isFileSyncPaused()) { + return@withLock FileSyncCenterActionResult.Rejected( + "Desktop syncing is paused. Resume it from the system tray first.", + ) + } + mutableFileSyncTraySnapshot.value = mutableFileSyncTraySnapshot.value.copy( + phase = DesktopFileSyncTrayPhase.Syncing, + message = "Checking folder changes", + ) + try { + fileSyncEngine.runPair( + session, + userId, + pairId, + onProgress = ::publishFileSyncProgress, + shouldContinue = { !isFileSyncPaused() }, + resetExhaustedFailures = true, + ) + } finally { + runCatching { + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + } + } + } + + override suspend fun resolveFileSyncConflict( + session: NextcloudSession, + userId: String, + pairId: String, + workId: Long, + choice: FileSyncDecisionChoice, + ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { + fileSyncRunLock.withLock { + if (isFileSyncPaused()) { + return@withLock FileSyncCenterActionResult.Rejected( + "Desktop syncing is paused. Resume it from the system tray first.", + ) + } + mutableFileSyncTraySnapshot.value = mutableFileSyncTraySnapshot.value.copy( + phase = DesktopFileSyncTrayPhase.Syncing, + message = "Resolving sync conflict", + ) + try { + fileSyncEngine.resolveConflictAndRun( + session, + userId, + pairId, + workId, + choice, + onProgress = ::publishFileSyncProgress, + shouldContinue = { !isFileSyncPaused() }, + ) + } finally { + runCatching { + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + } + } + } + + override suspend fun removeFileSyncPair( + session: NextcloudSession, + userId: String, + pairId: String, + ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { + fileSyncEngine.removePair(session, pairId).also { + runCatching { + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + } + } + + fun isFileSyncPaused(): Boolean = preferences.getBoolean(KEY_FILE_SYNC_PAUSED, false) + + override val supportsStartOnLogin: Boolean = true + + override fun loadStartOnLoginPreference(): Boolean = preferences.getBoolean(KEY_START_ON_LOGIN, true) + + override fun saveStartOnLoginPreference(enabled: Boolean): String? { + val result = startOnLoginController.configure(enabled) + if (result.configured) preferences.putBoolean(KEY_START_ON_LOGIN, enabled) + return result.message.takeUnless { result.configured } + } + + fun setFileSyncPaused(paused: Boolean) { + preferences.putBoolean(KEY_FILE_SYNC_PAUSED, paused) + val current = mutableFileSyncTraySnapshot.value + mutableFileSyncTraySnapshot.value = current.copy( + phase = if (paused) DesktopFileSyncTrayPhase.Paused else { + if (current.conflictCount + current.failedCount > 0) { + DesktopFileSyncTrayPhase.NeedsAttention + } else { + DesktopFileSyncTrayPhase.Idle + } + }, + message = if (paused) "Sync is paused" else null, + ) + if (!paused) { + serviceScope.launch { + runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Resume) } + .onFailure(::publishBackgroundFileSyncFailure) + } + } + } + + suspend fun refreshFileSyncTraySnapshot() = withContext(Dispatchers.IO) { + val session = loadSession() ?: return@withContext + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + + suspend fun syncAllFileSyncPairsFromTray(): FileSyncCenterActionResult = + syncAllFileSyncPairs(DesktopFileSyncRunSource.Tray) + + private suspend fun syncAllFileSyncPairs( + source: DesktopFileSyncRunSource, + ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { + fileSyncRunLock.withLock { + if (isFileSyncPaused()) { + return@withLock FileSyncCenterActionResult.Rejected("Desktop syncing is paused.") + } + val session = loadSession() + ?: return@withLock FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") + val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> + return@withLock FileSyncCenterActionResult.Rejected( + failure.message ?: "Could not load the signed-in account.", + ) + } + val initial = fileSyncEngine.loadCenter(session) + if (initial.pairs.isEmpty()) { + publishFileSyncTraySnapshot(initial, emptyList()) + return@withLock FileSyncCenterActionResult.Completed("No desktop sync folders are configured.") + } + mutableFileSyncTraySnapshot.value = mutableFileSyncTraySnapshot.value.copy( + phase = DesktopFileSyncTrayPhase.Syncing, + message = if (source == DesktopFileSyncRunSource.Background) { + "Checking for changes" + } else { + "Syncing all folders" + }, + accountLabel = session.loginName, + ) + try { + var failures = 0 + var waitingForConditions = 0 + initial.pairs.forEach { pair -> + if (isFileSyncPaused()) return@forEach + fun runtimeAllowsPair(): Boolean = + source == DesktopFileSyncRunSource.Tray || + desktopFileSyncRuntimeConditions().allows(pair.configuration) + if (!runtimeAllowsPair()) { + waitingForConditions += 1 + return@forEach + } + val result = try { + fileSyncEngine.runPair( + session, + userId, + pair.id, + onProgress = ::publishFileSyncProgress, + shouldContinue = { !isFileSyncPaused() && runtimeAllowsPair() }, + resetExhaustedFailures = source == DesktopFileSyncRunSource.Tray, + ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Throwable) { + failures += 1 + return@forEach + } + if (result is FileSyncCenterActionResult.Rejected) failures += 1 + if (source != DesktopFileSyncRunSource.Tray && !runtimeAllowsPair()) { + waitingForConditions += 1 + } + } + if (failures == 0) { + FileSyncCenterActionResult.Completed( + if (waitingForConditions == 0) { + "All desktop sync folders were checked." + } else { + "$waitingForConditions desktop sync folder(s) are waiting for their network or power rules." + }, + ) + } else { + FileSyncCenterActionResult.Rejected("$failures desktop sync folders need attention.") + } + } finally { + runCatching { + publishFileSyncTraySnapshot( + fileSyncEngine.loadCenter(session), + fileSyncEngine.loadTrayActivities(session), + ) + } + } + } + } + + private fun enforceCombinedVirtualFileCachePolicy( + accountId: String, + policy: VirtualFileCachePolicy, + ) { + fileReadCache.freeUpVirtualFiles(accountId, requestedBytesToFree = 0L) + virtualRangeCache.freeUp(accountId, requestedBytes = 0L) + synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId }?.enforcePolicy(policy) + } + if (!policy.automaticCleanup) return + val maximumBytes = policy.maximumCacheBytes ?: return + fun currentExcess(): Long { + val windows = windowsVirtualFileSummary(accountId) + return combinedAutomaticCacheExcess( + maximumBytes, + fileReadCache.virtualFileSummary(accountId).cachedBytes, + virtualRangeCache.summary(accountId).cachedBytes, + windows?.cachedBytes ?: 0L, + windows?.pinnedBytes ?: 0L, + ) + } + var excess = currentExcess() + if (excess == 0L) return + synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId }?.freeUpSpace(excess) + } + excess = currentExcess() + if (excess > 0L) virtualRangeCache.freeUp(accountId, excess) + excess = currentExcess() + if (excess > 0L) fileReadCache.freeUpVirtualFiles(accountId, excess) + } + + private fun windowsVirtualFileSummary(accountId: String): WindowsCloudFilesSummary? = + synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId }?.summary() + } + + private fun publishFileSyncTraySnapshot( + center: FileSyncCenterSnapshot, + durableActivities: List = emptyList(), + ) { + val conflicts = center.pairs.sumOf { it.conflicts.size } + val failed = center.pairs.sumOf(FileSyncPairSummary::failedCount) + val paused = isFileSyncPaused() + val recentCompleted = mutableFileSyncTraySnapshot.value.activities.filter { + it.phase == DesktopFileSyncTrayActivityPhase.Completed + } + val activities = (durableActivities + recentCompleted) + .distinctBy(DesktopFileSyncTrayActivity::stableId) + .take(MAX_TRAY_ACTIVITY_ITEMS) + mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( + phase = when { + paused -> DesktopFileSyncTrayPhase.Paused + conflicts + failed > 0 -> DesktopFileSyncTrayPhase.NeedsAttention + else -> DesktopFileSyncTrayPhase.Idle + }, + pairCount = center.pairs.size, + pendingCount = center.pairs.sumOf { it.readyCount + it.runningCount }, + conflictCount = conflicts, + failedCount = failed, + message = when { + paused -> "Sync is paused" + conflicts + failed > 0 -> "Open Nextcloud Native to review sync problems" + else -> null + }, + accountLabel = loadSession()?.loginName, + overallProgress = null, + activities = activities, + lastCheckedEpochMillis = center.pairs.mapNotNull(FileSyncPairSummary::lastScanEpochMillis).maxOrNull(), + ) + } + + private fun publishFileSyncProgress(event: DesktopFileSyncProgressEvent) { + val current = mutableFileSyncTraySnapshot.value + val phase = when (event.stage) { + DesktopFileSyncProgressStage.Started -> event.operation.toTrayActivityPhase() + DesktopFileSyncProgressStage.Completed -> DesktopFileSyncTrayActivityPhase.Completed + DesktopFileSyncProgressStage.Failed -> DesktopFileSyncTrayActivityPhase.Failed + } + val activity = DesktopFileSyncTrayActivity( + stableId = event.stableId, + relativePath = event.relativePath, + pairLabel = event.pairLabel, + phase = phase, + sizeBytes = event.sizeBytes, + detail = when (event.stage) { + DesktopFileSyncProgressStage.Started -> + "${event.completedOperations + 1} of ${event.totalOperations}" + DesktopFileSyncProgressStage.Completed -> "Synced safely" + DesktopFileSyncProgressStage.Failed -> event.failureMessage + }, + ) + val paused = isFileSyncPaused() + mutableFileSyncTraySnapshot.value = current.copy( + phase = if (paused) DesktopFileSyncTrayPhase.Paused else DesktopFileSyncTrayPhase.Syncing, + pendingCount = (event.totalOperations - event.completedOperations).coerceAtLeast(0), + message = if (paused) { + "Pausing after the current file" + } else when (phase) { + DesktopFileSyncTrayActivityPhase.Uploading -> "Uploading ${event.relativePath.substringAfterLast('/')}" + DesktopFileSyncTrayActivityPhase.Downloading -> + "Downloading ${event.relativePath.substringAfterLast('/')}" + DesktopFileSyncTrayActivityPhase.Failed -> "A sync item needs attention" + else -> "Applying ${event.relativePath.substringAfterLast('/')}" + }, + overallProgress = event.progressFraction, + activities = (listOf(activity) + current.activities.filterNot { it.stableId == activity.stableId }) + .take(MAX_TRAY_ACTIVITY_ITEMS), + ) + } + + private fun publishBackgroundFileSyncFailure(failure: Throwable) { + val current = mutableFileSyncTraySnapshot.value + val message = failure.message + ?.takeIf { it.isNotBlank() && it.none(Char::isISOControl) } + ?.take(1_024) + ?: "The automatic sync check failed." + mutableFileSyncTraySnapshot.value = current.copy( + phase = DesktopFileSyncTrayPhase.NeedsAttention, + failedCount = current.failedCount + 1, + message = message, + overallProgress = null, + ) + } + override fun loadThemePreference(): ThemePreference = runCatching { ThemePreference.valueOf(preferences.get(KEY_THEME, ThemePreference.System.name)) }.getOrDefault(ThemePreference.System) @@ -537,9 +1266,27 @@ class DesktopNextcloudServices( check(process.waitFor() == 0) { "Could not store the session in the desktop keyring." } preferences.put(KEY_SERVER, session.serverUrl) preferences.put(KEY_LOGIN, session.loginName) + startDesktopSyncLifecycle() } override fun clearSession() { + synchronized(this) { + backgroundFileSyncJob?.cancel() + backgroundFileSyncJob = null + } + synchronized(virtualFileProviderLock) { + runCatching { linuxVirtualFileSystem?.unmount() } + linuxVirtualFileSystem = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + runCatching { windowsCloudFilesProvider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + windowsCloudFilesFailure = null + } + mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.Idle, + ) val server = preferences.get(KEY_SERVER, null) val login = preferences.get(KEY_LOGIN, null) if (server != null && login != null) secretTool("clear", server, login) @@ -2150,6 +2897,9 @@ class DesktopNextcloudServices( const val KEY_LAST_OPENED_APP = "last_opened_app" const val KEY_SERVER = "server" const val KEY_LOGIN = "login" + const val KEY_FILE_SYNC_PAUSED = "file_sync_paused" + const val KEY_START_ON_LOGIN = "start_on_login" + const val DESKTOP_FILE_SYNC_INTERVAL_MILLIS = 2L * 60L * 1_000L const val USER_AGENT = "Nextcloud-Native/0.1.0 (Desktop)" const val DAV = "DAV:" const val OC = "http://owncloud.org/ns" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLogin.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLogin.kt new file mode 100644 index 000000000..4ee3d6807 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLogin.kt @@ -0,0 +1,171 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +internal enum class DesktopStartOnLoginPlatform { + Linux, + Windows, + Unsupported, +} + +internal data class DesktopStartOnLoginResult( + val platform: DesktopStartOnLoginPlatform, + val enabled: Boolean, + val configured: Boolean, + val message: String, +) + +internal class DesktopStartOnLoginController( + osName: String = System.getProperty("os.name").orEmpty(), + userHome: File = File(System.getProperty("user.home")), + private val linuxConfigHome: File = System.getenv("XDG_CONFIG_HOME") + ?.takeIf(String::isNotBlank) + ?.let(::File) + ?: File(userHome, ".config"), + private val launcherPath: String? = packagedDesktopLauncherPath(), + private val processRunner: (List) -> Int = { command -> + ProcessBuilder(command).redirectErrorStream(true).start().also { process -> + process.inputStream.bufferedReader().use { it.readText() } + }.waitFor() + }, +) { + private val platform = when { + osName.lowercase().contains("linux") -> DesktopStartOnLoginPlatform.Linux + osName.lowercase().contains("windows") -> DesktopStartOnLoginPlatform.Windows + else -> DesktopStartOnLoginPlatform.Unsupported + } + + fun configure(enabled: Boolean): DesktopStartOnLoginResult { + val launcher = launcherPath?.takeIf(String::isNotBlank) + ?: return DesktopStartOnLoginResult( + platform = platform, + enabled = enabled, + configured = false, + message = "Start on login is applied by installed desktop packages, not development launches.", + ) + return when (platform) { + DesktopStartOnLoginPlatform.Linux -> configureLinux(enabled, launcher) + DesktopStartOnLoginPlatform.Windows -> configureWindows(enabled, launcher) + DesktopStartOnLoginPlatform.Unsupported -> DesktopStartOnLoginResult( + platform = platform, + enabled = enabled, + configured = false, + message = "Start on login is not available on this desktop platform yet.", + ) + } + } + + private fun configureLinux(enabled: Boolean, launcher: String): DesktopStartOnLoginResult { + val entry = File(linuxConfigHome, "autostart/nextcloud-native.desktop") + if (!enabled) { + Files.deleteIfExists(entry.toPath()) + return DesktopStartOnLoginResult( + platform, + enabled = false, + configured = true, + message = "Nextcloud Native will not start when you sign in.", + ) + } + check(File(launcher).isFile) { "The installed Nextcloud Native launcher could not be found." } + val parent = requireNotNull(entry.parentFile) + check(parent.isDirectory || parent.mkdirs()) { "The desktop autostart folder could not be created." } + val content = """ + [Desktop Entry] + Type=Application + Version=1.0 + Name=Nextcloud Native + Comment=Keep Nextcloud files and virtual files available + Exec=${desktopEntryExecArgument(launcher)} + Icon=nextcloud-native + Terminal=false + StartupNotify=false + X-GNOME-Autostart-enabled=true + """.trimIndent() + "\n" + val temporary = File.createTempFile("nextcloud-native.", ".desktop", parent) + try { + temporary.writeText(content) + try { + Files.move( + temporary.toPath(), + entry.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary.toPath(), entry.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } finally { + temporary.delete() + } + return DesktopStartOnLoginResult( + platform, + enabled = true, + configured = true, + message = "Nextcloud Native will start when you sign in.", + ) + } + + private fun configureWindows(enabled: Boolean, launcher: String): DesktopStartOnLoginResult { + check(File(launcher).isFile) { "The installed Nextcloud Native launcher could not be found." } + val command = if (enabled) { + listOf( + "reg.exe", + "add", + WINDOWS_RUN_KEY, + "/v", + WINDOWS_VALUE_NAME, + "/t", + "REG_SZ", + "/d", + "\"$launcher\"", + "/f", + ) + } else { + listOf("reg.exe", "delete", WINDOWS_RUN_KEY, "/v", WINDOWS_VALUE_NAME, "/f") + } + val exitCode = processRunner(command) + check(exitCode == 0 || !enabled && exitCode == 1) { + "Windows could not update start on login (exit code $exitCode)." + } + return DesktopStartOnLoginResult( + platform, + enabled = enabled, + configured = true, + message = if (enabled) { + "Nextcloud Native will start when you sign in." + } else { + "Nextcloud Native will not start when you sign in." + }, + ) + } + + private companion object { + const val WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" + const val WINDOWS_VALUE_NAME = "NextcloudNative" + } +} + +internal fun packagedDesktopLauncherPath(): String? { + System.getProperty("jpackage.app-path")?.takeIf(String::isNotBlank)?.let { return it } + val command = ProcessHandle.current().info().command().orElse(null)?.takeIf(String::isNotBlank) ?: return null + val executableName = File(command).name.lowercase() + return command.takeUnless { + executableName == "java" || executableName == "java.exe" || executableName.startsWith("gradle") + } +} + +internal fun desktopEntryExecArgument(path: String): String { + require(path.isNotBlank() && '\n' !in path && '\r' !in path) + val escaped = buildString(path.length + 8) { + path.forEach { character -> + when (character) { + '\\', '"', '`', '$' -> append('\\').append(character) + else -> append(character) + } + } + } + return "\"$escaped\"" +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt new file mode 100644 index 000000000..626d09384 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt @@ -0,0 +1,337 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +internal data class DesktopVirtualRangeCacheSummary( + val cachedBytes: Long, + val reclaimableBytes: Long, + val fileCount: Int, + val availableFreeBytes: Long, +) + +/** Persistent exact-revision block cache used by the Linux virtual filesystem. */ +internal class DesktopVirtualRangeCache( + private val root: File, + private val maximumIndexBytes: Long = MAX_INDEX_BYTES, + private val policy: () -> VirtualFileCachePolicy, +) { + private val activePaths = mutableMapOf() + + init { + require(maximumIndexBytes in 1L..MAX_INDEX_BYTES) + } + + @Synchronized + fun acquire(accountId: String, path: String) { + val key = FileOfflineKey(accountId, path) + activePaths[key] = activePaths.getOrDefault(key, 0) + 1 + } + + @Synchronized + fun release(accountId: String, path: String) { + val key = FileOfflineKey(accountId, path) + val remaining = activePaths.getOrDefault(key, 1) - 1 + if (remaining <= 0) activePaths.remove(key) else activePaths[key] = remaining + } + + @Synchronized + fun readBlock( + accountId: String, + path: String, + remoteRevision: String, + fileSize: Long, + offset: Long, + length: Int, + nowEpochMillis: Long = System.currentTimeMillis(), + ): ByteArray? { + val normalized = FileOfflineKey(accountId, path).relativePath + val index = load(accountId) + val record = index.blocks.firstOrNull { block -> + block.path == normalized && + block.remoteRevision == remoteRevision && + block.fileSize == fileSize && + block.offset == offset && + block.length == length + } ?: return null + val blob = File(accountDirectory(accountId), record.blobName) + if (!blob.isFile || blob.length() != length.toLong()) { + removeRecord(accountId, index, record, blob) + return null + } + val bytes = blob.readBytes() + if (sha256Hex(bytes) != record.sha256) { + removeRecord(accountId, index, record, blob) + return null + } + save( + accountId, + index.copy( + blocks = index.blocks.map { current -> + if (current == record) current.copy(lastAccessedAtEpochMillis = nowEpochMillis) else current + }, + ), + ) + return bytes + } + + @Synchronized + fun storeBlock( + accountId: String, + path: String, + remoteRevision: String, + fileSize: Long, + offset: Long, + bytes: ByteArray, + nowEpochMillis: Long = System.currentTimeMillis(), + ) { + require(remoteRevision.isNotBlank()) + require(fileSize > 0L && offset >= 0L && bytes.isNotEmpty()) + require(offset + bytes.size <= fileSize) + require(bytes.size <= MAX_BLOCK_BYTES) + val normalized = FileOfflineKey(accountId, path).relativePath + val directory = accountDirectory(accountId).apply { + check(isDirectory || mkdirs()) { "Could not create the desktop virtual range cache." } + } + val identity = "$normalized\u0000$remoteRevision\u0000$fileSize\u0000$offset\u0000${bytes.size}" + val blobName = "${sha256Hex(identity)}.block" + val current = load(accountId) + val obsolete = current.blocks.filter { block -> + block.path == normalized && + (block.remoteRevision != remoteRevision || block.fileSize != fileSize || block.offset == offset) + } + val next = current.copy( + blocks = current.blocks.filterNot { it in obsolete } + CachedRangeBlock( + path = normalized, + remoteRevision = remoteRevision, + fileSize = fileSize, + offset = offset, + length = bytes.size, + blobName = blobName, + sha256 = sha256Hex(bytes), + cachedAtEpochMillis = nowEpochMillis, + lastAccessedAtEpochMillis = nowEpochMillis, + ), + ) + requireIndexFits(next) + val alreadyReferenced = current.blocks.any { block -> block.blobName == blobName } + try { + publishBytes(directory, blobName, bytes) + save(accountId, next) + } catch (failure: Throwable) { + if (!alreadyReferenced) File(directory, blobName).delete() + throw failure + } + applyEviction(accountId, 0L, nowEpochMillis) + } + + @Synchronized + fun invalidate(accountId: String, path: String) { + val normalized = FileOfflineKey(accountId, path).relativePath + val current = load(accountId) + val removed = current.blocks.filter { block -> + block.path == normalized || block.path.startsWith("$normalized/") + } + removed.forEach { block -> File(accountDirectory(accountId), block.blobName).delete() } + if (removed.isNotEmpty()) save(accountId, current.copy(blocks = current.blocks.filterNot { it in removed })) + } + + @Synchronized + fun summary(accountId: String): DesktopVirtualRangeCacheSummary { + val entries = load(accountId).toDomain(accountId) + val plan = planVirtualFileEviction( + entries = entries, + policy = policy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = System.currentTimeMillis(), + ) + return DesktopVirtualRangeCacheSummary( + cachedBytes = plan.cachedBytes, + reclaimableBytes = plan.reclaimableBytes, + fileCount = entries.size, + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + ) + } + + @Synchronized + fun freeUp(accountId: String, requestedBytes: Long): VirtualFileEvictionPlan = + applyEviction(accountId, requestedBytes, System.currentTimeMillis()) + + private fun applyEviction(accountId: String, requestedBytes: Long, nowEpochMillis: Long): VirtualFileEvictionPlan { + val current = load(accountId) + val plan = planVirtualFileEviction( + entries = current.toDomain(accountId), + policy = policy(), + availableFreeBytes = root.usableSpace.coerceAtLeast(0L), + nowEpochMillis = nowEpochMillis, + requestedBytesToFree = requestedBytes, + ) + val removedPaths = plan.evictions.mapNotNullTo(mutableSetOf()) { eviction -> + val matching = current.blocks.filter { it.path == eviction.key.relativePath } + if (matching.isEmpty() || activePaths.getOrDefault(eviction.key, 0) != 0) return@mapNotNullTo null + if (matching.localRevision() != eviction.expectedLocalRevision) return@mapNotNullTo null + matching.forEach { block -> File(accountDirectory(accountId), block.blobName).delete() } + eviction.key.relativePath + } + if (removedPaths.isNotEmpty()) { + save(accountId, current.copy(blocks = current.blocks.filterNot { it.path in removedPaths })) + } + return plan + } + + private fun RangeCacheIndex.toDomain(accountId: String): List = + blocks.groupBy(CachedRangeBlock::path).map { (path, fileBlocks) -> + VirtualFileCacheEntry( + key = FileOfflineKey(accountId, path), + remoteRevision = fileBlocks.first().remoteRevision, + localRevision = fileBlocks.localRevision(), + sizeBytes = fileBlocks.sumOf(CachedRangeBlock::length).toLong(), + cachedAtEpochMillis = fileBlocks.minOf(CachedRangeBlock::cachedAtEpochMillis), + lastAccessedAtEpochMillis = fileBlocks.maxOf(CachedRangeBlock::lastAccessedAtEpochMillis), + retention = VirtualFileRetention.Automatic, + activeLeaseCount = activePaths.getOrDefault(FileOfflineKey(accountId, path), 0), + ) + } + + private fun List.localRevision(): String = "sha256:" + sha256Hex( + sortedBy(CachedRangeBlock::offset).joinToString("|") { block -> + "${block.remoteRevision}:${block.offset}:${block.length}:${block.sha256}" + }, + ) + + private fun load(accountId: String): RangeCacheIndex { + val file = File(accountDirectory(accountId), INDEX_FILE) + if (!file.isFile || file.length() !in 1L..maximumIndexBytes) return RangeCacheIndex() + return runCatching { + rangeCacheJson.decodeFromString(file.readText()).also { index -> index.requireValid() } + }.getOrElse { RangeCacheIndex() } + } + + private fun save(accountId: String, index: RangeCacheIndex) { + val directory = accountDirectory(accountId).apply { + check(isDirectory || mkdirs()) { "Could not create the desktop virtual range cache." } + } + val bounded = boundedIndex(index) + val encoded = encodedIndex(bounded) + publishBytes(directory, INDEX_FILE, encoded) + val referenced = bounded.blocks.mapTo(hashSetOf(), CachedRangeBlock::blobName) + directory.listFiles().orEmpty() + .filter { it.isFile && it.extension == "block" && it.name !in referenced } + .forEach(File::delete) + } + + private fun requireIndexFits(index: RangeCacheIndex) { + encodedIndex(boundedIndex(index)) + } + + private fun boundedIndex(index: RangeCacheIndex): RangeCacheIndex = index.copy( + blocks = index.blocks.sortedByDescending(CachedRangeBlock::lastAccessedAtEpochMillis).take(MAX_BLOCKS), + ).also { bounded -> bounded.requireValid() } + + private fun encodedIndex(index: RangeCacheIndex): ByteArray = + rangeCacheJson.encodeToString(index).encodeToByteArray().also { encoded -> + require(encoded.size.toLong() <= maximumIndexBytes) { "The desktop virtual range index is too large." } + } + + private fun removeRecord(accountId: String, index: RangeCacheIndex, record: CachedRangeBlock, blob: File) { + blob.delete() + save(accountId, index.copy(blocks = index.blocks.filterNot { it == record })) + } + + private fun accountDirectory(accountId: String): File { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return File(root, accountId) + } + + private fun publishBytes(directory: File, name: String, bytes: ByteArray) { + val temporary = File.createTempFile("$name.", ".tmp", directory) + try { + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + File(directory, name).toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary.toPath(), File(directory, name).toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } finally { + temporary.delete() + } + } + + private fun RangeCacheIndex.requireValid() { + require(version == 1 && blocks.size <= MAX_BLOCKS) + require(blocks.map { "${it.path}\u0000${it.offset}" }.distinct().size == blocks.size) + blocks.forEach(CachedRangeBlock::requireValid) + } + + private companion object { + const val INDEX_FILE = "range-index-v1.json" + const val MAX_INDEX_BYTES = 16L * 1024L * 1024L + const val MAX_BLOCKS = 20_000 + const val MAX_BLOCK_BYTES = 4 * 1024 * 1024 + } +} + +internal fun defaultDesktopVirtualRangeCache( + policy: () -> VirtualFileCachePolicy, +): DesktopVirtualRangeCache { + val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) + val cacheRoot = xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") + return DesktopVirtualRangeCache( + root = File(cacheRoot, "nextcloud-native/virtual-ranges"), + policy = policy, + ) +} + +@Serializable +private data class RangeCacheIndex( + val version: Int = 1, + val blocks: List = emptyList(), +) + +@Serializable +private data class CachedRangeBlock( + val path: String, + val remoteRevision: String, + val fileSize: Long, + val offset: Long, + val length: Int, + val blobName: String, + val sha256: String, + val cachedAtEpochMillis: Long, + val lastAccessedAtEpochMillis: Long, +) { + fun requireValid() { + FileOfflineKey("account", path) + require(remoteRevision.isNotBlank()) + require(fileSize > 0L && offset >= 0L && length in 1..4 * 1024 * 1024) + require(offset + length <= fileSize) + require(blobName.length == 70 && blobName.endsWith(".block")) + require(sha256.length == 64) + require(cachedAtEpochMillis >= 0L && lastAccessedAtEpochMillis >= cachedAtEpochMillis) + } +} + +private fun sha256Hex(value: String): String = sha256Hex(value.encodeToByteArray()) + +private fun sha256Hex(value: ByteArray): String = MessageDigest.getInstance("SHA-256") + .digest(value).joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + +private val rangeCacheJson = Json { + encodeDefaults = true + ignoreUnknownKeys = false + explicitNulls = false +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt new file mode 100644 index 000000000..578af2376 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -0,0 +1,587 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import jnr.ffi.Pointer +import jnr.ffi.Platform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import ru.serce.jnrfuse.ErrorCodes +import ru.serce.jnrfuse.FuseFillDir +import ru.serce.jnrfuse.FuseStubFS +import ru.serce.jnrfuse.struct.FileStat +import ru.serce.jnrfuse.struct.FuseFileInfo + +internal data class LinuxVirtualFileNode( + val path: String, + val name: String, + val directory: Boolean, + val size: Long, + val remoteRevision: String, +) + +internal interface LinuxVirtualFileReadHandle : AutoCloseable { + val size: Long + fun read(offset: Long, length: Int): ByteArray + fun readdress(path: String) = Unit +} + +internal interface LinuxVirtualFileWriteHandle : AutoCloseable { + val size: Long + fun read(offset: Long, length: Int): ByteArray + fun write(offset: Long, bytes: ByteArray): Int + fun truncate(size: Long) + fun flush() +} + +internal interface LinuxVirtualFileBackend { + fun resolve(path: String): LinuxVirtualFileNode? + fun list(path: String): List + fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle + fun openWrite(path: String, existing: LinuxVirtualFileNode?, truncate: Boolean): LinuxVirtualFileWriteHandle + fun createDirectory(path: String) + fun delete(node: LinuxVirtualFileNode) + fun move(node: LinuxVirtualFileNode, destinationPath: String) + fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) +} + +/** Generation-pinned WebDAV backend shared by the Linux FUSE adapter and its unit tests. */ +internal class DesktopNextcloudVirtualFileBackend( + private val session: NextcloudSession, + private val userId: String, + private val services: NextcloudPlatformServices, + private val rangeCache: DesktopVirtualRangeCache, + private val writebacks: DesktopLinuxVirtualFileWritebackStore, + private val tree: DesktopFileSyncRemoteTree = DesktopFileSyncRemoteTree(session, userId, ""), +) : LinuxVirtualFileBackend { + private val accountId = desktopFileCacheAccountId(session) + + override fun resolve(path: String): LinuxVirtualFileNode? { + val normalized = path.linuxVirtualPath() + if (normalized.isEmpty()) return ROOT_NODE + return tree.resolve(normalized)?.toLinuxNode() + } + + override fun list(path: String): List = + tree.list(path.linuxVirtualPath()).map { document -> document.toLinuxNode() } + + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle { + require(!node.directory) + require(node.size > 0L) + return object : LinuxVirtualFileReadHandle { + private var currentPath = node.path + private var source = openRangeSource(currentPath) + private var closed = false + + init { + rangeCache.acquire(accountId, currentPath) + } + + override val size: Long = node.size + + @Synchronized + override fun read(offset: Long, length: Int): ByteArray { + check(!closed) + require(offset >= 0L && length > 0 && offset + length <= size) + val firstBlock = offset / RANGE_BLOCK_BYTES + val lastBlock = (offset + length - 1L) / RANGE_BLOCK_BYTES + val destination = ByteArray(length) + for (block in firstBlock..lastBlock) { + val blockOffset = block * RANGE_BLOCK_BYTES + val blockLength = minOf(RANGE_BLOCK_BYTES, size - blockOffset).toInt() + val bytes = runCatching { + rangeCache.readBlock( + accountId = accountId, + path = currentPath, + remoteRevision = node.remoteRevision, + fileSize = size, + offset = blockOffset, + length = blockLength, + ) + }.getOrNull() ?: runBlocking(Dispatchers.IO) { source.read(blockOffset, blockLength) }.also { fetched -> + runCatching { + rangeCache.storeBlock( + accountId = accountId, + path = currentPath, + remoteRevision = node.remoteRevision, + fileSize = size, + offset = blockOffset, + bytes = fetched, + ) + } + } + val copyStart = maxOf(offset, blockOffset) + val copyEnd = minOf(offset + length, blockOffset + blockLength) + bytes.copyInto( + destination = destination, + destinationOffset = (copyStart - offset).toInt(), + startIndex = (copyStart - blockOffset).toInt(), + endIndex = (copyEnd - blockOffset).toInt(), + ) + } + return destination + } + + @Synchronized + override fun readdress(path: String) { + check(!closed) + val normalized = path.linuxVirtualPath() + if (normalized == currentPath) return + val replacement = openRangeSource(normalized) + rangeCache.acquire(accountId, normalized) + val previousSource = source + val previousPath = currentPath + source = replacement + currentPath = normalized + runCatching(previousSource::close) + rangeCache.release(accountId, previousPath) + } + + @Synchronized + override fun close() { + if (closed) return + closed = true + source.close() + rangeCache.release(accountId, currentPath) + } + + private fun openRangeSource(path: String) = services.openFileRangeSession( + session = session, + userId = userId, + path = path, + size = node.size, + expectedEtag = node.remoteRevision, + ) + } + } + + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle = writebacks.open( + path = path.linuxVirtualPath(), + existing = existing, + truncate = truncate, + tree = tree, + onCommitted = { committedPath -> rangeCache.invalidate(accountId, committedPath) }, + ) + + override fun createDirectory(path: String) { + val normalized = path.linuxVirtualPath() + tree.createDirectory(normalized, expectedRemoteEtag = null) + rangeCache.invalidate(accountId, normalized) + } + + override fun delete(node: LinuxVirtualFileNode) { + tree.delete(node.path, node.remoteRevision) + rangeCache.invalidate(accountId, node.path) + } + + override fun move(node: LinuxVirtualFileNode, destinationPath: String) { + val normalized = destinationPath.linuxVirtualPath() + tree.move(node.path, normalized, node.remoteRevision) + rangeCache.invalidate(accountId, node.path) + rangeCache.invalidate(accountId, normalized) + } + + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) { + val normalized = destinationPath.linuxVirtualPath() + tree.moveReplacing(node.path, normalized, node.remoteRevision, destination.remoteRevision) + rangeCache.invalidate(accountId, node.path) + rangeCache.invalidate(accountId, normalized) + } + + private fun DesktopRemoteSyncDocument.toLinuxNode(): LinuxVirtualFileNode = LinuxVirtualFileNode( + path = entry.relativePath, + name = entry.relativePath.substringAfterLast('/'), + directory = isDirectory, + size = entry.size ?: 0L, + remoteRevision = entry.etag, + ) + + private companion object { + const val RANGE_BLOCK_BYTES = 1024L * 1024L + val ROOT_NODE = LinuxVirtualFileNode("", "Nextcloud", true, 0L, "root") + } +} + +/** + * Linux filesystem provider for remote Nextcloud metadata and seekable file content. + * + * Reads use persistent revision-pinned blocks. Writes are staged durably, uploaded with ETag + * preconditions, and surfaced through flush so applications receive a real error when writeback + * cannot be committed. Failed close-time writeback remains in recovery storage. + */ +internal class LinuxNextcloudVirtualFileSystem( + private val backend: LinuxVirtualFileBackend, +) : FuseStubFS() { + private val nextHandle = AtomicLong(1L) + private val readHandles = ConcurrentHashMap() + private val readHandlePaths = ConcurrentHashMap() + private val writeHandles = ConcurrentHashMap() + private val pendingCreatedFiles = ConcurrentHashMap() + private val namespaceLock = Any() + + override fun getattr(path: String, stat: FileStat): Int = fuseResult { + val normalized = path.linuxVirtualPath() + val pending = pendingCreatedFiles[normalized]?.delegate + val node = visibleNode(normalized) + ?: pending?.let { LinuxVirtualFileNode(normalized, normalized.substringAfterLast('/'), false, it.size, "pending") } + ?: return -ErrorCodes.ENOENT() + stat.st_mode.set( + if (node.directory) FileStat.S_IFDIR or DIRECTORY_PERMISSIONS + else FileStat.S_IFREG or FILE_PERMISSIONS, + ) + stat.st_nlink.set(if (node.directory) 2 else 1) + stat.st_size.set(node.size) + stat.st_uid.set(context.uid.get()) + stat.st_gid.set(context.gid.get()) + 0 + } + + override fun readdir( + path: String, + buffer: Pointer, + filler: FuseFillDir, + offset: Long, + fileInfo: FuseFileInfo, + ): Int = fuseResult { + val normalized = path.linuxVirtualPath() + val directory = visibleNode(normalized) ?: return -ErrorCodes.ENOENT() + if (!directory.directory) return -ErrorCodes.ENOTDIR() + val visibleNames = LinkedHashSet() + backend.list(normalized) + .forEach { node -> visibleNames += node.name } + pendingCreatedFiles.keys + .asSequence() + .filter { pending -> pending.substringBeforeLast('/', "") == normalized } + .map { pending -> pending.substringAfterLast('/') } + .filter(visibleNames::add) + .toList() + val entries = listOf(".", "..") + visibleNames.sorted() + if (offset < 0L || offset > entries.size.toLong()) return -ErrorCodes.EINVAL() + for (index in offset.toInt() until entries.size) { + if (filler.apply(buffer, entries[index], null, index.toLong() + 1L) != 0) break + } + 0 + } + + override fun open(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + val normalized = path.linuxVirtualPath() + val flags = fileInfo.flags.intValue() + val writeAccess = flags and OPEN_ACCESS_MASK != OPEN_READ_ONLY + pendingCreatedFiles[normalized]?.let { pending -> + if (writeAccess && flags and OPEN_TRUNCATE != 0) pending.delegate.truncate(0L) + fileInfo.fh.set(registerWriteHandle(pending, writable = writeAccess)) + return 0 + } + synchronized(namespaceLock) { + val node = visibleNode(normalized) ?: return -ErrorCodes.ENOENT() + if (node.directory) return -ErrorCodes.EISDIR() + if (writeAccess) { + val shared = LinuxSharedWriteHandle( + backend.openWrite(normalized, node, truncate = flags and OPEN_TRUNCATE != 0), + normalized, + ) + fileInfo.fh.set(registerWriteHandle(shared, writable = true)) + return 0 + } + if (node.size == 0L) { + fileInfo.fh.set(EMPTY_FILE_HANDLE) + return 0 + } + val id = nextHandle.getAndIncrement() + readHandles[id] = backend.open(node) + readHandlePaths[id] = normalized + fileInfo.fh.set(id) + } + 0 + } + + override fun read( + path: String, + buffer: Pointer, + requestedSize: Long, + offset: Long, + fileInfo: FuseFileInfo, + ): Int = fuseResult { + if (offset < 0L || requestedSize < 0L || requestedSize > Int.MAX_VALUE) return -ErrorCodes.EINVAL() + val id = fileInfo.fh.get() + if (id == EMPTY_FILE_HANDLE) return 0 + val handle = readHandles[id] + val writeHandle = writeHandles[id]?.shared?.delegate + if (handle == null && writeHandle == null) return -ErrorCodes.EBADF() + val handleSize = handle?.size ?: requireNotNull(writeHandle).size + if (offset >= handleSize) return 0 + val length = minOf(requestedSize, handleSize - offset).toInt() + val bytes = handle?.read(offset, length) ?: requireNotNull(writeHandle).read(offset, length) + check(bytes.size == length) { "The Linux virtual file range was incomplete." } + buffer.put(0L, bytes, 0, bytes.size) + bytes.size + } + + override fun release(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + val id = fileInfo.fh.get() + if (id != EMPTY_FILE_HANDLE) { + synchronized(namespaceLock) { + readHandlePaths.remove(id) + readHandles.remove(id)?.close() + } + releaseWriteHandle(id) + } + 0 + } + + override fun access(path: String, mask: Int): Int = fuseResult { + val normalized = path.linuxVirtualPath() + if (pendingCreatedFiles.containsKey(normalized) || visibleNode(normalized) != null) 0 else -ErrorCodes.ENOENT() + } + + override fun create(path: String, mode: Long, fi: FuseFileInfo?): Int = fuseResult { + val fileInfo = fi ?: return -ErrorCodes.EINVAL() + val normalized = path.linuxVirtualPath() + val parent = visibleNode(normalized.substringBeforeLast('/', "")) + ?: return -ErrorCodes.ENOENT() + if (!parent.directory) return -ErrorCodes.ENOTDIR() + synchronized(pendingCreatedFiles) { + if (visibleNode(normalized) != null || pendingCreatedFiles.containsKey(normalized)) { + return -ErrorCodes.EEXIST() + } + val shared = LinuxSharedWriteHandle( + backend.openWrite(normalized, existing = null, truncate = true), + normalized, + ) + pendingCreatedFiles[normalized] = shared + fileInfo.fh.set(registerWriteHandle(shared, writable = true)) + } + 0 + } + + override fun mkdir(path: String, mode: Long): Int = fuseResult { + val normalized = path.linuxVirtualPath() + val parent = visibleNode(normalized.substringBeforeLast('/', "")) + ?: return -ErrorCodes.ENOENT() + if (!parent.directory) return -ErrorCodes.ENOTDIR() + if (visibleNode(normalized) != null) return -ErrorCodes.EEXIST() + backend.createDirectory(normalized) + 0 + } + + override fun unlink(path: String): Int = deletePath(path, expectDirectory = false) + + override fun rmdir(path: String): Int = deletePath(path, expectDirectory = true) + + override fun rename(oldPath: String, newPath: String): Int = fuseResult { + synchronized(namespaceLock) { + val sourcePath = oldPath.linuxVirtualPath() + val destination = newPath.linuxVirtualPath() + if (sourcePath == destination) return 0 + if (pendingCreatedFiles.containsKey(sourcePath)) return -ErrorCodes.EBUSY() + if (hasOpenWriteHandleWithin(sourcePath) || hasOpenWriteHandleWithin(destination)) { + return -ErrorCodes.EBUSY() + } + val source = visibleNode(sourcePath) ?: return -ErrorCodes.ENOENT() + val parent = visibleNode(destination.substringBeforeLast('/', "")) + ?: return -ErrorCodes.ENOENT() + if (!parent.directory) return -ErrorCodes.ENOTDIR() + if (pendingCreatedFiles.containsKey(destination)) return -ErrorCodes.EBUSY() + val existingDestination = visibleNode(destination) + if (existingDestination != null) { + if (source.directory && !existingDestination.directory) return -ErrorCodes.ENOTDIR() + if (!source.directory && existingDestination.directory) return -ErrorCodes.EISDIR() + if (existingDestination.directory && backend.list(destination).isNotEmpty()) { + return -ErrorCodes.ENOTEMPTY() + } + if (hasOpenReadHandleWithin(destination)) return -ErrorCodes.EBUSY() + backend.moveReplacing(source, existingDestination, destination) + } else { + backend.move(source, destination) + } + readdressReadHandles(sourcePath, destination) + 0 + } + } + + override fun truncate(path: String, size: Long): Int = fuseResult { + val normalized = path.linuxVirtualPath() + pendingCreatedFiles[normalized]?.let { pending -> + pending.delegate.truncate(size) + pending.delegate.flush() + return 0 + } + val existing = visibleNode(normalized) ?: return -ErrorCodes.ENOENT() + if (existing.directory) return -ErrorCodes.EISDIR() + backend.openWrite(normalized, existing, truncate = false).use { handle -> + handle.truncate(size) + handle.flush() + } + 0 + } + + override fun write(path: String, buf: Pointer, size: Long, offset: Long, fi: FuseFileInfo): Int = fuseResult { + if (offset < 0L || size < 0L || size > Int.MAX_VALUE) return -ErrorCodes.EINVAL() + val reference = writeHandles[fi.fh.get()] ?: return -ErrorCodes.EBADF() + if (!reference.writable) return -ErrorCodes.EBADF() + val bytes = ByteArray(size.toInt()) + buf.get(0L, bytes, 0, bytes.size) + reference.shared.delegate.write(offset, bytes) + } + + override fun flush(path: String, fi: FuseFileInfo): Int = fuseResult { + writeHandles[fi.fh.get()]?.shared?.delegate?.flush() + 0 + } + + override fun fsync(path: String, isDataSync: Int, fi: FuseFileInfo): Int = flush(path, fi) + + fun mountAt(mountPoint: Path, blocking: Boolean = false, debug: Boolean = false) { + require(Platform.getNativePlatform().os == Platform.OS.LINUX) { + "The Linux virtual filesystem can only be mounted on Linux." + } + require(mountPoint.toFile().let { it.isDirectory && it.canWrite() }) { + "The Linux virtual filesystem mount point must be a writable directory." + } + mount(mountPoint, blocking, debug, arrayOf("-o", "fsname=nextcloud-native", "-o", "default_permissions")) + } + + fun unmount() { + readHandles.values.forEach { runCatching(it::close) } + writeHandles.values.map(LinuxOpenWriteReference::shared).distinct().forEach { shared -> + runCatching(shared.delegate::close) + } + readHandles.clear() + readHandlePaths.clear() + writeHandles.clear() + pendingCreatedFiles.clear() + umount() + } + + private fun registerWriteHandle(shared: LinuxSharedWriteHandle, writable: Boolean): Long { + synchronized(shared) { + check(!shared.closed) + shared.referenceCount += 1 + } + val id = nextHandle.getAndIncrement() + writeHandles[id] = LinuxOpenWriteReference(shared, writable) + return id + } + + private fun releaseWriteHandle(id: Long) { + val reference = writeHandles.remove(id) ?: return + val shared = reference.shared + val close = synchronized(shared) { + check(shared.referenceCount > 0) + shared.referenceCount -= 1 + if (shared.referenceCount == 0 && !shared.closed) { + shared.closed = true + true + } else { + false + } + } + if (close) { + try { + shared.delegate.close() + } finally { + pendingCreatedFiles.entries.removeIf { it.value === shared } + } + } + } + + private fun readdressReadHandles(sourcePath: String, destinationPath: String) { + readHandlePaths.entries.toList().forEach { (id, openPath) -> + val movedPath = when { + openPath == sourcePath -> destinationPath + openPath.startsWith("$sourcePath/") -> destinationPath + openPath.removePrefix(sourcePath) + else -> return@forEach + } + readHandles[id]?.readdress(movedPath) + readHandlePaths.replace(id, openPath, movedPath) + } + } + + private fun hasOpenWriteHandleWithin(path: String): Boolean = + writeHandles.values.any { reference -> + reference.shared.path == path || reference.shared.path.startsWith("$path/") + } + + private fun hasOpenReadHandleWithin(path: String): Boolean = + readHandlePaths.values.any { openPath -> + openPath == path || openPath.startsWith("$path/") + } + + private fun visibleNode(path: String): LinuxVirtualFileNode? = backend.resolve(path) + + private fun deletePath(path: String, expectDirectory: Boolean): Int = fuseResult { + synchronized(namespaceLock) { + val normalized = path.linuxVirtualPath() + if (pendingCreatedFiles.containsKey(normalized)) return -ErrorCodes.EBUSY() + val node = visibleNode(normalized) ?: return -ErrorCodes.ENOENT() + if (node.directory != expectDirectory) { + return if (expectDirectory) -ErrorCodes.ENOTDIR() else -ErrorCodes.EISDIR() + } + if (expectDirectory) { + val hasRemoteChildren = backend.list(normalized).isNotEmpty() + val hasPendingChildren = pendingCreatedFiles.keys.any { pending -> + pending.substringBeforeLast('/', "") == normalized + } + if (hasRemoteChildren || hasPendingChildren) return -ErrorCodes.ENOTEMPTY() + } + if (!expectDirectory && + (readHandlePaths.containsValue(normalized) || hasOpenWriteHandleWithin(normalized)) + ) { + return -ErrorCodes.EBUSY() + } + backend.delete(node) + 0 + } + } + + private inline fun fuseResult(operation: () -> Int): Int = try { + operation() + } catch (_: IllegalArgumentException) { + -ErrorCodes.EINVAL() + } catch (_: Throwable) { + -ErrorCodes.EIO() + } + + private companion object { + const val DIRECTORY_PERMISSIONS = 0b111101101 // 0755 + const val FILE_PERMISSIONS = 0b110100100 // 0644 + const val EMPTY_FILE_HANDLE = 0L + const val OPEN_ACCESS_MASK = 0x3 + const val OPEN_READ_ONLY = 0x0 + const val OPEN_TRUNCATE = 0x200 + } +} + +private class LinuxSharedWriteHandle( + val delegate: LinuxVirtualFileWriteHandle, + val path: String, +) { + var referenceCount: Int = 0 + var closed: Boolean = false +} + +private data class LinuxOpenWriteReference( + val shared: LinuxSharedWriteHandle, + val writable: Boolean, +) + +private fun String.linuxVirtualPath(): String { + val normalized = trim('/') + if (normalized.isEmpty()) return "" + require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) + require('\u0000' !in normalized) + return normalized +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt new file mode 100644 index 000000000..3c3041364 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt @@ -0,0 +1,705 @@ +package dev.obiente.nextcloudnative.app + +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.WString +import com.sun.jna.platform.win32.Guid +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.WinBase +import com.sun.jna.platform.win32.WinNT +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.LongByReference +import com.sun.jna.win32.StdCallLibrary +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +/** 64-bit Windows CldApi.dll binding kept behind [WindowsCloudFilesApi] for deterministic tests. */ +internal class JnaWindowsCloudFilesApi : WindowsCloudFilesApi { + private val cldApi: CldApi + private val kernelFiles: KernelFileApi + private val callbacksByConnection = ConcurrentHashMap() + + init { + require(isWindowsDesktop()) { "CldApi.dll is only available on Windows." } + require(Native.POINTER_SIZE == 8) { "Nextcloud Native Cloud Files requires 64-bit Windows." } + cldApi = Native.load("CldApi", CldApi::class.java) + kernelFiles = Native.load("kernel32", KernelFileApi::class.java) + } + + override fun registerSyncRoot(root: Path, syncRootIdentity: ByteArray) { + val identity = syncRootIdentity.nativeMemory() + val registration = CfSyncRegistration().apply { + structSize = size() + providerName = WString("Nextcloud Native") + providerVersion = WString("0.1.0") + syncRootIdentityPointer = identity + syncRootIdentityLength = syncRootIdentity.size + fileIdentity = identity + fileIdentityLength = syncRootIdentity.size + providerId = Guid.GUID("{6D456713-7D9A-4A39-90CE-127998DE42D7}") + write() + } + val policies = CfSyncPolicies().apply { + structSize = size() + hydration = CfPolicy(CF_HYDRATION_POLICY_PROGRESSIVE, CF_HYDRATION_POLICY_MODIFIER_AUTO_DEHYDRATION_ALLOWED) + population = CfPolicy(CF_POPULATION_POLICY_FULL, 0) + inSync = CF_INSYNC_POLICY_TRACK_FILE_CREATION_TIME or + CF_INSYNC_POLICY_TRACK_FILE_LAST_WRITE_TIME or + CF_INSYNC_POLICY_TRACK_DIRECTORY_CREATION_TIME or + CF_INSYNC_POLICY_TRACK_DIRECTORY_LAST_WRITE_TIME + hardLink = CF_HARDLINK_POLICY_NONE + placeholderManagement = CF_PLACEHOLDER_MANAGEMENT_POLICY_DEFAULT + write() + } + checkHResult( + cldApi.CfRegisterSyncRoot( + WString(root.toAbsolutePath().toString()), + registration, + policies, + CF_REGISTER_FLAG_UPDATE or CF_REGISTER_FLAG_MARK_IN_SYNC_ON_ROOT, + ), + "register the Windows Cloud Files root", + ) + identity.clear() + } + + override fun connect(root: Path, callbacks: WindowsCloudFilesCallbacks): Long { + val types = intArrayOf( + CF_CALLBACK_TYPE_FETCH_DATA, + CF_CALLBACK_TYPE_CANCEL_FETCH_DATA, + CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS, + CF_CALLBACK_TYPE_CANCEL_FETCH_PLACEHOLDERS, + CF_CALLBACK_TYPE_NOTIFY_FILE_CLOSE_COMPLETION, + CF_CALLBACK_TYPE_NOTIFY_DELETE, + CF_CALLBACK_TYPE_NOTIFY_RENAME, + CF_CALLBACK_TYPE_NONE, + ) + val nativeCallbacks = types.filter { it != CF_CALLBACK_TYPE_NONE }.associateWith { callbackType -> + CldCallback { infoPointer, parametersPointer -> + currentCallbackType.set(callbackType) + try { + runCatching { dispatchCallback(callbacks, infoPointer, parametersPointer) } + } finally { + currentCallbackType.remove() + } + } + } + val registrations = CfCallbackRegistration().toArray(types.size).map { it as CfCallbackRegistration } + types.forEachIndexed { index, type -> + registrations[index].type = type + registrations[index].callback = nativeCallbacks[type] + registrations[index].write() + } + val key = LongByReference() + checkHResult( + cldApi.CfConnectSyncRoot( + WString(root.toAbsolutePath().toString()), + registrations.first().pointer, + null, + CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + key, + ), + "connect the Windows Cloud Files provider", + ) + val value = key.value + callbacksByConnection[value] = CallbackLifetime(nativeCallbacks.values.toList(), registrations) + return value + } + + override fun disconnect(connectionKey: Long) { + checkHResult(cldApi.CfDisconnectSyncRoot(connectionKey), "disconnect the Windows Cloud Files provider") + callbacksByConnection.remove(connectionKey) + } + + override fun createPlaceholders(baseDirectory: Path, placeholders: List) { + if (placeholders.isEmpty()) return + val native = NativePlaceholderArray(placeholders) + val processed = IntByReference() + checkHResult( + cldApi.CfCreatePlaceholders( + WString(baseDirectory.toAbsolutePath().toString()), + native.firstPointer, + placeholders.size, + CF_CREATE_FLAG_STOP_ON_ERROR, + processed, + ), + "create Windows Cloud Files placeholders", + ) + check(processed.value == placeholders.size) { "Windows created only some requested placeholders." } + native.requireSuccessful() + } + + override fun transferData(info: WindowsCloudCallbackInfo, offset: Long, bytes: ByteArray) { + val buffer = bytes.nativeMemory() + execute(info, CF_OPERATION_TYPE_TRANSFER_DATA, TRANSFER_PARAMETERS_SIZE) { parameters -> + parameters.setInt(TRANSFER_FLAGS_OFFSET, 0) + parameters.setInt(TRANSFER_STATUS_OFFSET, STATUS_SUCCESS) + parameters.setPointer(TRANSFER_BUFFER_OFFSET, buffer) + parameters.setLong(TRANSFER_OFFSET_OFFSET, offset) + parameters.setLong(TRANSFER_LENGTH_OFFSET, bytes.size.toLong()) + } + buffer.clear() + } + + override fun failData( + info: WindowsCloudCallbackInfo, + offset: Long, + length: Long, + message: String, + ) { + execute(info, CF_OPERATION_TYPE_TRANSFER_DATA, TRANSFER_PARAMETERS_SIZE) { parameters -> + parameters.setInt(TRANSFER_FLAGS_OFFSET, 0) + parameters.setInt(TRANSFER_STATUS_OFFSET, STATUS_CLOUD_FILE_UNSUCCESSFUL) + parameters.setPointer(TRANSFER_BUFFER_OFFSET, null) + parameters.setLong(TRANSFER_OFFSET_OFFSET, offset) + parameters.setLong(TRANSFER_LENGTH_OFFSET, length) + } + } + + override fun completePlaceholderFetch( + info: WindowsCloudCallbackInfo, + placeholders: List, + ) { + val native = NativePlaceholderArray(placeholders) + execute(info, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, PLACEHOLDER_PARAMETERS_SIZE) { parameters -> + parameters.setInt(PLACEHOLDERS_FLAGS_OFFSET, 0) + parameters.setInt(PLACEHOLDERS_STATUS_OFFSET, STATUS_SUCCESS) + parameters.setLong(PLACEHOLDERS_TOTAL_OFFSET, placeholders.size.toLong()) + parameters.setPointer(PLACEHOLDERS_ARRAY_OFFSET, native.firstPointer) + parameters.setInt(PLACEHOLDERS_COUNT_OFFSET, placeholders.size) + parameters.setInt(PLACEHOLDERS_PROCESSED_OFFSET, 0) + } + } + + override fun failPlaceholderFetch(info: WindowsCloudCallbackInfo) { + execute(info, CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS, PLACEHOLDER_PARAMETERS_SIZE) { parameters -> + parameters.setInt(PLACEHOLDERS_FLAGS_OFFSET, 0) + parameters.setInt(PLACEHOLDERS_STATUS_OFFSET, STATUS_CLOUD_FILE_UNSUCCESSFUL) + parameters.setLong(PLACEHOLDERS_TOTAL_OFFSET, 0L) + parameters.setPointer(PLACEHOLDERS_ARRAY_OFFSET, null) + parameters.setInt(PLACEHOLDERS_COUNT_OFFSET, 0) + parameters.setInt(PLACEHOLDERS_PROCESSED_OFFSET, 0) + } + } + + override fun acknowledgeDelete(info: WindowsCloudCallbackInfo, accepted: Boolean) { + execute(info, CF_OPERATION_TYPE_ACK_DELETE, ACK_PARAMETERS_SIZE) { parameters -> + parameters.setInt(ACK_FLAGS_OFFSET, 0) + parameters.setInt(ACK_STATUS_OFFSET, if (accepted) STATUS_SUCCESS else STATUS_CLOUD_FILE_UNSUCCESSFUL) + } + } + + override fun acknowledgeRename(info: WindowsCloudCallbackInfo, accepted: Boolean) { + execute(info, CF_OPERATION_TYPE_ACK_RENAME, ACK_PARAMETERS_SIZE) { parameters -> + parameters.setInt(ACK_FLAGS_OFFSET, 0) + parameters.setInt(ACK_STATUS_OFFSET, if (accepted) STATUS_SUCCESS else STATUS_CLOUD_FILE_UNSUCCESSFUL) + } + } + + override fun placeholderState(path: Path): WindowsCloudPlaceholderState { + return withFindData(path, WindowsCloudPlaceholderState.Absent) { findData -> + val state = cldApi.CfGetPlaceholderStateFromFindData(findData.pointer) + when { + state and CF_PLACEHOLDER_STATE_PLACEHOLDER == 0 -> WindowsCloudPlaceholderState.Absent + state and CF_PLACEHOLDER_STATE_IN_SYNC != 0 -> WindowsCloudPlaceholderState.InSync + else -> WindowsCloudPlaceholderState.Dirty + } + } + } + + override fun allocatedBytes(path: Path): Long { + if (!Files.isRegularFile(path)) return 0L + val high = IntByReference() + val low = kernelFiles.GetCompressedFileSizeW(WString(path.toAbsolutePath().toString()), high) + if (low == -1 && Native.getLastError() != 0) return 0L + return ((high.value.toLong() and 0xffff_ffffL) shl 32) or (low.toLong() and 0xffff_ffffL) + } + + override fun lastAccessedAtEpochMillis(path: Path): Long = + withFindData(path, 0L) { findData -> findData.ftLastAccessTime.toTime().coerceAtLeast(0L) } + + override fun isPinned(path: Path): Boolean = withFindData(path, false) { findData -> + findData.dwFileAttributes and FILE_ATTRIBUTE_PINNED != 0 + } + + override fun placeholderIdentity(path: Path): ByteArray? { + if (!Files.exists(path)) return null + return runCatching { + withFileHandle(path, write = false) { handle -> + val buffer = Memory(CF_STANDARD_INFO_BUFFER_BYTES.toLong()).apply { clear() } + val returned = IntByReference() + checkHResult( + cldApi.CfGetPlaceholderInfo( + handle, + CF_PLACEHOLDER_INFO_STANDARD, + buffer, + CF_STANDARD_INFO_BUFFER_BYTES, + returned, + ), + "read a Windows Cloud Files placeholder identity", + ) + val identityLength = buffer.getInt(CF_STANDARD_INFO_IDENTITY_LENGTH_OFFSET.toLong()) + require(identityLength in 1..MAX_PLACEHOLDER_IDENTITY_BYTES) + require( + CF_STANDARD_INFO_IDENTITY_OFFSET + identityLength <= returned.value && + CF_STANDARD_INFO_IDENTITY_OFFSET + identityLength <= CF_STANDARD_INFO_BUFFER_BYTES, + ) + buffer.getByteArray(CF_STANDARD_INFO_IDENTITY_OFFSET.toLong(), identityLength) + } + }.getOrNull() + } + + override fun updatePlaceholder( + path: Path, + placeholder: WindowsCloudPlaceholder, + invalidateContent: Boolean, + preserveSyncState: Boolean, + ) { + require(!invalidateContent || !preserveSyncState) + withFileHandle(path, write = true, exclusive = invalidateContent) { handle -> + val metadata = placeholder.metadata() + val identity = placeholder.identity.nativeMemory() + val flags = if (preserveSyncState) { + 0 + } else { + CF_UPDATE_FLAG_MARK_IN_SYNC or CF_UPDATE_FLAG_VERIFY_IN_SYNC or + if (invalidateContent) CF_UPDATE_FLAG_DEHYDRATE else 0 + } + checkHResult( + cldApi.CfUpdatePlaceholder( + handle, + metadata, + identity, + placeholder.identity.size, + null, + 0, + flags, + null, + null, + ), + "update a Windows Cloud Files placeholder", + ) + identity.clear() + } + } + + override fun convertToPlaceholder(path: Path, placeholder: WindowsCloudPlaceholder) { + withFileHandle(path, write = true) { handle -> + val identity = placeholder.identity.nativeMemory() + checkHResult( + cldApi.CfConvertToPlaceholder( + handle, + identity, + placeholder.identity.size, + CF_CONVERT_FLAG_MARK_IN_SYNC, + null, + null, + ), + "convert a local item to a Windows Cloud Files placeholder", + ) + identity.clear() + } + } + + override fun markInSync(path: Path) { + withFileHandle(path, write = true) { handle -> + checkHResult( + cldApi.CfSetInSyncState(handle, CF_IN_SYNC_STATE_IN_SYNC, 0, null), + "mark a Windows Cloud Files placeholder in sync", + ) + } + } + + override fun dehydrate(path: Path): Long { + if (!Files.isRegularFile(path)) return 0L + val size = Files.size(path) + withFileHandle(path, write = true) { handle -> + checkHResult( + cldApi.CfDehydratePlaceholder(handle, 0L, -1L, 0, null), + "dehydrate a Windows Cloud Files placeholder", + ) + } + return size + } + + override fun close() { + callbacksByConnection.keys.toList().forEach { key -> runCatching { disconnect(key) } } + } + + private fun dispatchCallback(callbacks: WindowsCloudFilesCallbacks, infoPointer: Pointer, parameters: Pointer) { + val nativeInfo = CfCallbackInfo(infoPointer).apply { read() } + val identity = nativeInfo.fileIdentity?.takeIf { nativeInfo.fileIdentityLength > 0 } + ?.getByteArray(0L, nativeInfo.fileIdentityLength) + val info = WindowsCloudCallbackInfo( + connectionKey = nativeInfo.connectionKey, + transferKey = nativeInfo.transferKey, + requestKey = nativeInfo.requestKey, + normalizedPath = nativeInfo.normalizedPath?.toString().orEmpty(), + fileIdentity = identity, + fileSize = nativeInfo.fileSize, + priorityHint = nativeInfo.priorityHint.toInt() and 0xff, + ) + val type = requireNotNull(currentCallbackType.get()) { "The Cloud Files callback type was not bound." } + val union = PARAMETERS_UNION_OFFSET + when (type) { + CF_CALLBACK_TYPE_FETCH_DATA -> callbacks.fetchData( + info, + parameters.getLong(union + 8L), + parameters.getLong(union + 16L), + ) + CF_CALLBACK_TYPE_CANCEL_FETCH_DATA -> callbacks.cancelFetchData( + info, + parameters.getLong(union + 8L), + parameters.getLong(union + 16L), + ) + CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS -> callbacks.fetchPlaceholders( + info, + parameters.getPointer(union + 8L)?.getWideString(0L), + ) + CF_CALLBACK_TYPE_CANCEL_FETCH_PLACEHOLDERS -> callbacks.cancelFetchPlaceholders(info) + CF_CALLBACK_TYPE_NOTIFY_FILE_CLOSE_COMPLETION -> callbacks.closed( + info, + parameters.getInt(union).and(CF_CALLBACK_CLOSE_COMPLETION_FLAG_DELETED) != 0, + ) + CF_CALLBACK_TYPE_NOTIFY_DELETE -> callbacks.deleteRequested(info) + CF_CALLBACK_TYPE_NOTIFY_RENAME -> callbacks.renameRequested( + info, + requireNotNull(parameters.getPointer(union + 8L)).getWideString(0L), + ) + } + } + + private fun execute( + info: WindowsCloudCallbackInfo, + operationType: Int, + parameterSize: Int, + fill: (Memory) -> Unit, + ) { + val operation = Memory(OPERATION_INFO_SIZE.toLong()).apply { + clear() + setInt(0L, OPERATION_INFO_SIZE) + setInt(4L, operationType) + setLong(8L, info.connectionKey) + setLong(16L, info.transferKey) + setLong(40L, info.requestKey) + } + val parameters = Memory(OPERATION_PARAMETERS_SIZE.toLong()).apply { + clear() + setInt(0L, parameterSize) + fill(this) + } + checkHResult(cldApi.CfExecute(operation, parameters), "complete a Windows Cloud Files callback") + } + + private inline fun withFileHandle( + path: Path, + write: Boolean, + exclusive: Boolean = false, + block: (WinNT.HANDLE) -> T, + ): T { + val handle = Kernel32.INSTANCE.CreateFile( + path.toAbsolutePath().toString(), + if (write) WinNT.GENERIC_WRITE else WinNT.FILE_READ_ATTRIBUTES, + if (exclusive) 0 else WinNT.FILE_SHARE_READ or WinNT.FILE_SHARE_WRITE or WinNT.FILE_SHARE_DELETE, + null, + WinNT.OPEN_EXISTING, + WinNT.FILE_FLAG_BACKUP_SEMANTICS, + null, + ) + check(handle != WinBase.INVALID_HANDLE_VALUE) { "Could not open the Windows Cloud Files placeholder." } + return try { + block(handle) + } finally { + Kernel32.INSTANCE.CloseHandle(handle) + } + } + + private inline fun withFindData(path: Path, fallback: T, block: (WinBase.WIN32_FIND_DATA) -> T): T { + if (!Files.exists(path)) return fallback + val findData = WinBase.WIN32_FIND_DATA() + val handle = Kernel32.INSTANCE.FindFirstFile(path.toAbsolutePath().toString(), findData.pointer) + if (WinBase.INVALID_HANDLE_VALUE == handle) return fallback + return try { + findData.read() + block(findData) + } finally { + Kernel32.INSTANCE.FindClose(handle) + } + } + + private fun checkHResult(result: Int, operation: String) { + check(result >= 0) { "Could not $operation (HRESULT 0x${result.toUInt().toString(16)})." } + } + + private data class CallbackLifetime( + val callbacks: List, + val registrations: List, + ) + + private inner class NativePlaceholderArray(placeholders: List) { + private val names = placeholders.map { it.name.wideMemory() } + private val identities = placeholders.map { it.identity.nativeMemory() } + private val entries: List = if (placeholders.isEmpty()) { + emptyList() + } else { + CfPlaceholderCreateInfo().toArray(placeholders.size) + .map { it as CfPlaceholderCreateInfo } + .also { array -> + placeholders.forEachIndexed { index, placeholder -> + array[index].relativeFileName = names[index] + array[index].metadata = placeholder.metadata() + array[index].fileIdentity = identities[index] + array[index].fileIdentityLength = placeholder.identity.size + array[index].flags = CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC + array[index].write() + } + } + } + val firstPointer: Pointer? get() = entries.firstOrNull()?.pointer + + fun requireSuccessful() { + entries.forEach { entry -> + entry.read() + check(entry.result >= 0) { "Windows rejected a Cloud Files placeholder (HRESULT 0x${entry.result.toUInt().toString(16)})." } + } + } + } + + private companion object { + val currentCallbackType = ThreadLocal() + + const val CF_HYDRATION_POLICY_PROGRESSIVE = 1 + const val CF_HYDRATION_POLICY_MODIFIER_AUTO_DEHYDRATION_ALLOWED = 0x4 + const val CF_POPULATION_POLICY_FULL = 2 + const val CF_INSYNC_POLICY_TRACK_FILE_CREATION_TIME = 0x1 + const val CF_INSYNC_POLICY_TRACK_FILE_LAST_WRITE_TIME = 0x100 + const val CF_INSYNC_POLICY_TRACK_DIRECTORY_CREATION_TIME = 0x10 + const val CF_INSYNC_POLICY_TRACK_DIRECTORY_LAST_WRITE_TIME = 0x200 + const val CF_HARDLINK_POLICY_NONE = 0 + const val CF_PLACEHOLDER_MANAGEMENT_POLICY_DEFAULT = 0 + const val CF_REGISTER_FLAG_UPDATE = 0x1 + const val CF_REGISTER_FLAG_MARK_IN_SYNC_ON_ROOT = 0x4 + const val CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH = 0x2 + + const val CF_CALLBACK_TYPE_FETCH_DATA = 0 + const val CF_CALLBACK_TYPE_CANCEL_FETCH_DATA = 2 + const val CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS = 3 + const val CF_CALLBACK_TYPE_CANCEL_FETCH_PLACEHOLDERS = 4 + const val CF_CALLBACK_TYPE_NOTIFY_FILE_CLOSE_COMPLETION = 6 + const val CF_CALLBACK_TYPE_NOTIFY_DELETE = 9 + const val CF_CALLBACK_TYPE_NOTIFY_RENAME = 11 + const val CF_CALLBACK_TYPE_NONE = -1 + const val CF_CALLBACK_CLOSE_COMPLETION_FLAG_DELETED = 0x1 + + const val CF_OPERATION_TYPE_TRANSFER_DATA = 0 + const val CF_OPERATION_TYPE_TRANSFER_PLACEHOLDERS = 4 + const val CF_OPERATION_TYPE_ACK_RENAME = 6 + const val CF_OPERATION_TYPE_ACK_DELETE = 7 + const val STATUS_SUCCESS = 0 + const val STATUS_CLOUD_FILE_UNSUCCESSFUL = -1_073_688_814 // 0xC000CF12 + + const val CF_PLACEHOLDER_STATE_PLACEHOLDER = 0x1 + const val CF_PLACEHOLDER_STATE_IN_SYNC = 0x8 + const val CF_CREATE_FLAG_STOP_ON_ERROR = 0x1 + const val CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC = 0x2 + const val CF_UPDATE_FLAG_MARK_IN_SYNC = 0x2 + const val CF_UPDATE_FLAG_DEHYDRATE = 0x4 + const val CF_UPDATE_FLAG_VERIFY_IN_SYNC = 0x1 + const val CF_CONVERT_FLAG_MARK_IN_SYNC = 0x1 + const val CF_PLACEHOLDER_INFO_STANDARD = 1 + const val MAX_PLACEHOLDER_IDENTITY_BYTES = 4_096 + const val CF_STANDARD_INFO_IDENTITY_LENGTH_OFFSET = 56 + const val CF_STANDARD_INFO_IDENTITY_OFFSET = 60 + const val CF_STANDARD_INFO_BUFFER_BYTES = CF_STANDARD_INFO_IDENTITY_OFFSET + MAX_PLACEHOLDER_IDENTITY_BYTES + const val CF_IN_SYNC_STATE_IN_SYNC = 1 + const val FILE_ATTRIBUTE_PINNED = 0x0008_0000 + + const val PARAMETERS_UNION_OFFSET = 8L + const val OPERATION_INFO_SIZE = 48 + const val OPERATION_PARAMETERS_SIZE = 64 + const val TRANSFER_PARAMETERS_SIZE = 40 + const val PLACEHOLDER_PARAMETERS_SIZE = 40 + const val ACK_PARAMETERS_SIZE = 16 + const val TRANSFER_FLAGS_OFFSET = PARAMETERS_UNION_OFFSET + const val TRANSFER_STATUS_OFFSET = PARAMETERS_UNION_OFFSET + 4L + const val TRANSFER_BUFFER_OFFSET = PARAMETERS_UNION_OFFSET + 8L + const val TRANSFER_OFFSET_OFFSET = PARAMETERS_UNION_OFFSET + 16L + const val TRANSFER_LENGTH_OFFSET = PARAMETERS_UNION_OFFSET + 24L + const val PLACEHOLDERS_FLAGS_OFFSET = PARAMETERS_UNION_OFFSET + const val PLACEHOLDERS_STATUS_OFFSET = PARAMETERS_UNION_OFFSET + 4L + const val PLACEHOLDERS_TOTAL_OFFSET = PARAMETERS_UNION_OFFSET + 8L + const val PLACEHOLDERS_ARRAY_OFFSET = PARAMETERS_UNION_OFFSET + 16L + const val PLACEHOLDERS_COUNT_OFFSET = PARAMETERS_UNION_OFFSET + 24L + const val PLACEHOLDERS_PROCESSED_OFFSET = PARAMETERS_UNION_OFFSET + 28L + const val ACK_FLAGS_OFFSET = PARAMETERS_UNION_OFFSET + const val ACK_STATUS_OFFSET = PARAMETERS_UNION_OFFSET + 4L + } +} + +internal interface CldApi : StdCallLibrary { + fun CfRegisterSyncRoot(path: WString, registration: CfSyncRegistration, policies: CfSyncPolicies, flags: Int): Int + fun CfConnectSyncRoot(path: WString, callbackTable: Pointer, context: Pointer?, flags: Int, key: LongByReference): Int + fun CfDisconnectSyncRoot(key: Long): Int + fun CfCreatePlaceholders(path: WString, placeholders: Pointer?, count: Int, flags: Int, processed: IntByReference): Int + fun CfExecute(operationInfo: Pointer, operationParameters: Pointer): Int + fun CfGetPlaceholderStateFromFindData(findData: Pointer): Int + fun CfGetPlaceholderInfo( + handle: WinNT.HANDLE, + infoClass: Int, + infoBuffer: Pointer, + infoBufferLength: Int, + returnedLength: IntByReference?, + ): Int + fun CfUpdatePlaceholder( + handle: WinNT.HANDLE, + metadata: CfFsMetadata?, + identity: Pointer?, + identityLength: Int, + ranges: Pointer?, + rangeCount: Int, + flags: Int, + usn: LongByReference?, + overlapped: Pointer?, + ): Int + fun CfConvertToPlaceholder( + handle: WinNT.HANDLE, + identity: Pointer?, + identityLength: Int, + flags: Int, + usn: LongByReference?, + overlapped: Pointer?, + ): Int + fun CfSetInSyncState(handle: WinNT.HANDLE, state: Int, flags: Int, usn: LongByReference?): Int + fun CfDehydratePlaceholder(handle: WinNT.HANDLE, offset: Long, length: Long, flags: Int, overlapped: Pointer?): Int +} + +internal interface KernelFileApi : StdCallLibrary { + fun GetCompressedFileSizeW(path: WString, high: IntByReference): Int +} + +internal fun interface CldCallback : StdCallLibrary.StdCallCallback { + fun invoke(info: Pointer, parameters: Pointer) +} + +@Structure.FieldOrder("structSize", "providerName", "providerVersion", "syncRootIdentityPointer", "syncRootIdentityLength", "fileIdentity", "fileIdentityLength", "providerId") +internal class CfSyncRegistration : Structure() { + @JvmField var structSize: Int = 0 + @JvmField var providerName: WString? = null + @JvmField var providerVersion: WString? = null + @JvmField var syncRootIdentityPointer: Pointer? = null + @JvmField var syncRootIdentityLength: Int = 0 + @JvmField var fileIdentity: Pointer? = null + @JvmField var fileIdentityLength: Int = 0 + @JvmField var providerId: Guid.GUID = Guid.GUID() +} + +@Structure.FieldOrder("primary", "modifier") +internal class CfPolicy() : Structure() { + @JvmField var primary: Short = 0 + @JvmField var modifier: Short = 0 + constructor(primary: Int, modifier: Int) : this() { + this.primary = primary.toShort() + this.modifier = modifier.toShort() + } +} + +@Structure.FieldOrder("structSize", "hydration", "population", "inSync", "hardLink", "placeholderManagement") +internal class CfSyncPolicies : Structure() { + @JvmField var structSize: Int = 0 + @JvmField var hydration: CfPolicy = CfPolicy() + @JvmField var population: CfPolicy = CfPolicy() + @JvmField var inSync: Int = 0 + @JvmField var hardLink: Int = 0 + @JvmField var placeholderManagement: Int = 0 +} + +@Structure.FieldOrder("type", "callback") +internal class CfCallbackRegistration : Structure() { + @JvmField var type: Int = 0 + @JvmField var callback: CldCallback? = null +} + +@Structure.FieldOrder("creationTime", "lastAccessTime", "lastWriteTime", "changeTime", "fileAttributes", "fileSize") +internal class CfFsMetadata : Structure() { + @JvmField var creationTime: Long = 0L + @JvmField var lastAccessTime: Long = 0L + @JvmField var lastWriteTime: Long = 0L + @JvmField var changeTime: Long = 0L + @JvmField var fileAttributes: Int = 0 + @JvmField var fileSize: Long = 0L +} + +@Structure.FieldOrder("relativeFileName", "metadata", "fileIdentity", "fileIdentityLength", "flags", "result", "createUsn") +internal class CfPlaceholderCreateInfo : Structure() { + @JvmField var relativeFileName: Pointer? = null + @JvmField var metadata: CfFsMetadata = CfFsMetadata() + @JvmField var fileIdentity: Pointer? = null + @JvmField var fileIdentityLength: Int = 0 + @JvmField var flags: Int = 0 + @JvmField var result: Int = 0 + @JvmField var createUsn: Long = 0L +} + +@Structure.FieldOrder( + "structSize", "connectionKey", "callbackContext", "volumeGuidName", "volumeDosName", + "volumeSerialNumber", "syncRootFileId", "syncRootIdentity", "syncRootIdentityLength", + "fileId", "fileSize", "fileIdentity", "fileIdentityLength", "normalizedPath", "transferKey", + "priorityHint", "correlationVector", "processInfo", "requestKey", +) +internal class CfCallbackInfo(pointer: Pointer) : Structure(pointer) { + @JvmField var structSize: Int = 0 + @JvmField var connectionKey: Long = 0L + @JvmField var callbackContext: Pointer? = null + @JvmField var volumeGuidName: WString? = null + @JvmField var volumeDosName: WString? = null + @JvmField var volumeSerialNumber: Int = 0 + @JvmField var syncRootFileId: Long = 0L + @JvmField var syncRootIdentity: Pointer? = null + @JvmField var syncRootIdentityLength: Int = 0 + @JvmField var fileId: Long = 0L + @JvmField var fileSize: Long = 0L + @JvmField var fileIdentity: Pointer? = null + @JvmField var fileIdentityLength: Int = 0 + @JvmField var normalizedPath: WString? = null + @JvmField var transferKey: Long = 0L + @JvmField var priorityHint: Byte = 0 + @JvmField var correlationVector: Pointer? = null + @JvmField var processInfo: Pointer? = null + @JvmField var requestKey: Long = 0L +} + +private fun WindowsCloudPlaceholder.metadata(): CfFsMetadata = CfFsMetadata().apply { + fileAttributes = if (directory) WinNT.FILE_ATTRIBUTE_DIRECTORY else WinNT.FILE_ATTRIBUTE_ARCHIVE + fileSize = size + write() +} + +private fun ByteArray.nativeMemory(): Memory = Memory(size.toLong()).also { memory -> + memory.write(0L, this, 0, size) +} + +private fun String.wideMemory(): Memory = Memory(((length + 1) * Native.WCHAR_SIZE).toLong()).also { memory -> + memory.setWideString(0L, this) +} + +internal fun isWindowsDesktop(): Boolean = + System.getProperty("os.name").orEmpty().lowercase().contains("windows") + +internal data class WindowsCloudNativeLayoutSizes( + val registration: Int, + val policies: Int, + val fileSystemMetadata: Int, + val placeholder: Int, + val callbackInfo: Int, +) + +internal fun windowsCloudNativeLayoutSizes(): WindowsCloudNativeLayoutSizes = WindowsCloudNativeLayoutSizes( + registration = CfSyncRegistration().size(), + policies = CfSyncPolicies().size(), + fileSystemMetadata = CfFsMetadata().size(), + placeholder = CfPlaceholderCreateInfo().size(), + callbackInfo = CfCallbackInfo(Memory(160L)).size(), +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt new file mode 100644 index 000000000..3be04b09f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt @@ -0,0 +1,1066 @@ +package dev.obiente.nextcloudnative.app + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchService +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +internal data class WindowsCloudFileIdentity( + val accountId: String, + val path: String, + val remoteRevision: String, + val size: Long, + val directory: Boolean, +) { + init { + require(accountId.isNotBlank() && accountId.length <= MAX_ACCOUNT_ID_LENGTH) + if (path.isNotEmpty()) FileOfflineKey(accountId, path) + require(remoteRevision.isNotBlank() && remoteRevision.length <= MAX_REVISION_LENGTH) + require(size >= 0L) + require(!directory || size == 0L) + } + + private companion object { + const val MAX_ACCOUNT_ID_LENGTH = 256 + const val MAX_REVISION_LENGTH = 1_024 + } +} + +/** Versioned, checksummed and strictly bounded identity persisted in Windows placeholders. */ +internal object WindowsCloudFileIdentityCodec { + fun encode(identity: WindowsCloudFileIdentity): ByteArray { + val payload = ByteArrayOutputStream().use { bytes -> + DataOutputStream(bytes).use { output -> + output.writeInt(MAGIC) + output.writeShort(VERSION) + output.writeBoolean(identity.directory) + output.writeLong(identity.size) + output.writeBoundedUtf8(identity.accountId, MAX_ACCOUNT_BYTES) + output.writeBoundedUtf8(identity.path, MAX_PATH_BYTES) + output.writeBoundedUtf8(identity.remoteRevision, MAX_REVISION_BYTES) + } + bytes.toByteArray() + } + val digest = MessageDigest.getInstance("SHA-256").digest(payload) + return payload + digest + } + + fun decode(bytes: ByteArray): WindowsCloudFileIdentity { + require(bytes.size in MIN_IDENTITY_BYTES..MAX_IDENTITY_BYTES) { + "The Windows placeholder identity has an invalid size." + } + val payload = bytes.copyOfRange(0, bytes.size - DIGEST_BYTES) + val expectedDigest = bytes.copyOfRange(bytes.size - DIGEST_BYTES, bytes.size) + require(MessageDigest.getInstance("SHA-256").digest(payload).contentEquals(expectedDigest)) { + "The Windows placeholder identity checksum is invalid." + } + return DataInputStream(ByteArrayInputStream(payload)).use { input -> + require(input.readInt() == MAGIC) { "The Windows placeholder identity type is invalid." } + require(input.readUnsignedShort() == VERSION) { "The Windows placeholder identity version is unsupported." } + val directory = input.readBoolean() + val size = input.readLong() + val accountId = input.readBoundedUtf8(MAX_ACCOUNT_BYTES) + val path = input.readBoundedUtf8(MAX_PATH_BYTES) + val revision = input.readBoundedUtf8(MAX_REVISION_BYTES) + require(input.available() == 0) { "The Windows placeholder identity has trailing data." } + WindowsCloudFileIdentity(accountId, path, revision, size, directory) + } + } + + private fun DataOutputStream.writeBoundedUtf8(value: String, maximumBytes: Int) { + val bytes = value.encodeToByteArray() + require(bytes.size <= maximumBytes) + writeShort(bytes.size) + write(bytes) + } + + private fun DataInputStream.readBoundedUtf8(maximumBytes: Int): String { + val length = readUnsignedShort() + require(length <= maximumBytes && length <= available()) { + "The Windows placeholder identity field is invalid." + } + val bytes = ByteArray(length).also(::readFully) + return bytes.decodeToString(throwOnInvalidSequence = true) + } + + private const val MAGIC = 0x4E434656 // NCFV + private const val VERSION = 1 + private const val DIGEST_BYTES = 32 + private const val MAX_ACCOUNT_BYTES = 256 + private const val MAX_PATH_BYTES = 3_072 + private const val MAX_REVISION_BYTES = 1_024 + private const val MAX_IDENTITY_BYTES = 4_096 + private const val MIN_IDENTITY_BYTES = 4 + 2 + 1 + 8 + 2 + 2 + 2 + DIGEST_BYTES +} + +internal data class WindowsCloudHydrationRange(val offset: Long, val length: Int) { + init { + require(offset >= 0L && offset % WINDOWS_CLOUD_ALIGNMENT == 0L) + require(length > 0) + } +} + +/** Produces CfExecute-compatible chunks; only the final range may end unaligned at EOF. */ +internal fun planWindowsCloudHydration( + requiredOffset: Long, + requiredLength: Long, + fileSize: Long, + maximumChunkBytes: Int = 4 * 1024 * 1024, +): List { + require(requiredOffset >= 0L && requiredLength > 0L && fileSize > 0L) + require( + maximumChunkBytes.toLong() >= WINDOWS_CLOUD_ALIGNMENT && + maximumChunkBytes.toLong() % WINDOWS_CLOUD_ALIGNMENT == 0L, + ) + val requiredEnd = minOf(fileSize, Math.addExact(requiredOffset, requiredLength)) + require(requiredOffset < requiredEnd) + val transferEnd = if (requiredEnd == fileSize) { + fileSize + } else { + minOf(fileSize, Math.addExact(requiredEnd, WINDOWS_CLOUD_ALIGNMENT - 1L) / WINDOWS_CLOUD_ALIGNMENT * WINDOWS_CLOUD_ALIGNMENT) + } + var cursor = requiredOffset - requiredOffset % WINDOWS_CLOUD_ALIGNMENT + return buildList { + while (cursor < transferEnd) { + val remaining = transferEnd - cursor + val length = minOf(maximumChunkBytes.toLong(), remaining).toInt() + add(WindowsCloudHydrationRange(cursor, length)) + cursor += length + } + } +} + +internal data class WindowsCloudCallbackInfo( + val connectionKey: Long, + val transferKey: Long, + val requestKey: Long, + val normalizedPath: String, + val fileIdentity: ByteArray?, + val fileSize: Long, + val priorityHint: Int, +) + +internal interface WindowsCloudFilesCallbacks { + fun fetchData(info: WindowsCloudCallbackInfo, requiredOffset: Long, requiredLength: Long) + fun cancelFetchData(info: WindowsCloudCallbackInfo, offset: Long, length: Long) + fun fetchPlaceholders(info: WindowsCloudCallbackInfo, pattern: String?) + fun cancelFetchPlaceholders(info: WindowsCloudCallbackInfo) + fun closed(info: WindowsCloudCallbackInfo, deleted: Boolean) + fun deleteRequested(info: WindowsCloudCallbackInfo) + fun renameRequested(info: WindowsCloudCallbackInfo, targetPath: String) +} + +internal data class WindowsCloudPlaceholder( + val name: String, + val identity: ByteArray, + val size: Long, + val directory: Boolean, + val lastModifiedEpochMillis: Long? = null, +) { + init { + require(name.isNotBlank() && name.none { it == '/' || it == '\\' || it == '\u0000' }) + require(identity.size <= 4_096) + require(size >= 0L) + } +} + +internal enum class WindowsCloudPlaceholderState { + Absent, + InSync, + Dirty, +} + +internal interface WindowsCloudFilesApi : AutoCloseable { + fun registerSyncRoot(root: Path, syncRootIdentity: ByteArray) + fun connect(root: Path, callbacks: WindowsCloudFilesCallbacks): Long + fun disconnect(connectionKey: Long) + fun createPlaceholders(baseDirectory: Path, placeholders: List) + fun transferData(info: WindowsCloudCallbackInfo, offset: Long, bytes: ByteArray) + fun failData(info: WindowsCloudCallbackInfo, offset: Long, length: Long, message: String) + fun completePlaceholderFetch(info: WindowsCloudCallbackInfo, placeholders: List) + fun failPlaceholderFetch(info: WindowsCloudCallbackInfo) + fun acknowledgeDelete(info: WindowsCloudCallbackInfo, accepted: Boolean) + fun acknowledgeRename(info: WindowsCloudCallbackInfo, accepted: Boolean) + fun placeholderState(path: Path): WindowsCloudPlaceholderState + fun allocatedBytes(path: Path): Long + fun lastAccessedAtEpochMillis(path: Path): Long + fun isPinned(path: Path): Boolean + fun placeholderIdentity(path: Path): ByteArray? + fun updatePlaceholder( + path: Path, + placeholder: WindowsCloudPlaceholder, + invalidateContent: Boolean = false, + preserveSyncState: Boolean = false, + ) + fun convertToPlaceholder(path: Path, placeholder: WindowsCloudPlaceholder) + fun markInSync(path: Path) + fun dehydrate(path: Path): Long +} + +internal interface WindowsCloudFileReadHandle : AutoCloseable { + val size: Long + fun read(offset: Long, length: Int): ByteArray +} + +internal data class WindowsCloudFilesSummary( + val cachedBytes: Long, + val reclaimableBytes: Long, + val pinnedBytes: Long, + val hydratedFileCount: Int, + val pinnedFileCount: Int, + val availableFreeBytes: Long, + val pendingWritebackCount: Int, + val failedWritebackCount: Int, +) + +internal interface WindowsCloudFilesBackend { + val accountId: String + fun resolve(path: String): WindowsCloudFileIdentity? + fun list(path: String): List + fun open(identity: WindowsCloudFileIdentity): WindowsCloudFileReadHandle + fun upload(path: String, localFile: File, expectedRemoteRevision: String?): WindowsCloudFileIdentity + fun createDirectory(path: String): WindowsCloudFileIdentity + fun delete(identity: WindowsCloudFileIdentity) + fun move(identity: WindowsCloudFileIdentity, destinationPath: String): WindowsCloudFileIdentity +} + +internal class DesktopNextcloudWindowsCloudFilesBackend( + private val session: NextcloudSession, + private val userId: String, + private val services: NextcloudPlatformServices, + private val tree: DesktopFileSyncRemoteTree = DesktopFileSyncRemoteTree(session, userId, ""), +) : WindowsCloudFilesBackend { + override val accountId: String = desktopFileCacheAccountId(session) + + override fun resolve(path: String): WindowsCloudFileIdentity? = + tree.resolve(path.windowsCloudPath())?.toWindowsIdentity() + + override fun list(path: String): List = + tree.list(path.windowsCloudPath()).map { it.toWindowsIdentity() } + + override fun open(identity: WindowsCloudFileIdentity): WindowsCloudFileReadHandle { + require(identity.accountId == accountId && !identity.directory && identity.size > 0L) + val source = services.openFileRangeSession( + session = session, + userId = userId, + path = identity.path, + size = identity.size, + expectedEtag = identity.remoteRevision, + ) + return object : WindowsCloudFileReadHandle { + override val size: Long = source.size + override fun read(offset: Long, length: Int): ByteArray = + runBlocking(Dispatchers.IO) { source.read(offset, length) } + override fun close() = source.close() + } + } + + override fun upload( + path: String, + localFile: File, + expectedRemoteRevision: String?, + ): WindowsCloudFileIdentity = tree.writeFile(path.windowsCloudPath(), localFile, expectedRemoteRevision) + .let { uploaded -> + WindowsCloudFileIdentity(accountId, uploaded.relativePath, uploaded.etag, uploaded.size ?: localFile.length(), false) + } + + override fun createDirectory(path: String): WindowsCloudFileIdentity { + val normalized = path.windowsCloudPath() + tree.createDirectory(normalized, expectedRemoteEtag = null) + return requireNotNull(resolve(normalized)) + } + + override fun delete(identity: WindowsCloudFileIdentity) { + require(identity.accountId == accountId) + tree.delete(identity.path, identity.remoteRevision) + } + + override fun move(identity: WindowsCloudFileIdentity, destinationPath: String): WindowsCloudFileIdentity { + require(identity.accountId == accountId) + val destination = destinationPath.windowsCloudPath() + tree.move(identity.path, destination, identity.remoteRevision) + return requireNotNull(resolve(destination)) + } + + private fun DesktopRemoteSyncDocument.toWindowsIdentity(): WindowsCloudFileIdentity = + WindowsCloudFileIdentity( + accountId = accountId, + path = entry.relativePath, + remoteRevision = entry.etag, + size = entry.size ?: 0L, + directory = isDirectory, + ) +} + +/** + * Windows Cloud Files provider lifecycle and callback coordinator. + * + * Native callbacks are dispatched away from the Cloud Filter thread. Hydration is generation + * pinned, random-seek capable, cancellable, and transferred in 4 KiB aligned chunks. Namespace + * mutations are accepted only after the corresponding ETag-guarded WebDAV operation succeeds. + */ +internal class WindowsCloudFilesProvider( + private val root: Path, + private val backend: WindowsCloudFilesBackend, + private val api: WindowsCloudFilesApi, + private val executor: ExecutorService = Executors.newFixedThreadPool(4) { work -> + Thread(work, "nextcloud-windows-cloud-files").apply { isDaemon = true } + }, + private val writebackRetryDelayMillis: (attempt: Int) -> Long = ::windowsWritebackRetryDelayMillis, +) : AutoCloseable, WindowsCloudFilesCallbacks { + private val connection = AtomicLongState() + private val cancelledRequests = ConcurrentHashMap() + private val knownIdentities = ConcurrentHashMap() + private val pathOperations = ConcurrentHashMap.newKeySet() + private val queuedPathOperations = ConcurrentHashMap Unit>() + private val pendingWritebacks = ConcurrentHashMap.newKeySet() + private val failedWritebacks = ConcurrentHashMap.newKeySet() + private val writebackAttempts = ConcurrentHashMap() + private val namespaceMutationLock = Any() + private val localChangeScheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { work -> + Thread(work, "nextcloud-windows-local-changes").apply { isDaemon = true } + } + private val pendingLocalChanges = ConcurrentHashMap>() + @Volatile private var watchService: WatchService? = null + @Volatile private var watcherThread: Thread? = null + + fun start() { + check(connection.get() == 0L) { "The Windows Cloud Files provider is already connected." } + Files.createDirectories(root) + check(!Files.isSymbolicLink(root)) { "The Windows Cloud Files root cannot be a symlink." } + val rootIdentity = WindowsCloudFileIdentity(backend.accountId, "", "root", 0L, true) + api.registerSyncRoot(root, WindowsCloudFileIdentityCodec.encode(rootIdentity)) + populateDirectory("", root) + connection.set(api.connect(root, this)) + startLocalWatcher() + executor.execute(::recoverLocalChanges) + } + + override fun fetchData(info: WindowsCloudCallbackInfo, requiredOffset: Long, requiredLength: Long) { + val cancellation = AtomicBoolean(false) + cancelledRequests[info.requestKey] = cancellation + executor.execute { + val identity = runCatching { requireIdentity(info, expectDirectory = false) }.getOrElse { failure -> + api.failData(info, requiredOffset, requiredLength, failure.message ?: "Invalid placeholder identity") + cancelledRequests.remove(info.requestKey) + return@execute + } + try { + backend.open(identity).use { source -> + check(source.size == identity.size) { "The remote file generation has a different size." } + planWindowsCloudHydration(requiredOffset, requiredLength, identity.size).forEach { range -> + if (cancellation.get()) return@execute + val bytes = source.read(range.offset, range.length) + check(bytes.size == range.length) { "The remote file returned an incomplete range." } + api.transferData(info, range.offset, bytes) + } + } + } catch (failure: Throwable) { + if (!cancellation.get()) { + api.failData(info, requiredOffset, requiredLength, failure.message ?: "Hydration failed") + } + } finally { + cancelledRequests.remove(info.requestKey) + } + } + } + + override fun cancelFetchData(info: WindowsCloudCallbackInfo, offset: Long, length: Long) { + cancelledRequests[info.requestKey]?.set(true) + } + + override fun fetchPlaceholders(info: WindowsCloudCallbackInfo, pattern: String?) { + val cancellation = AtomicBoolean(false) + cancelledRequests[info.requestKey] = cancellation + executor.execute { + try { + val directory = requireIdentity(info, expectDirectory = true) + val identities = backend.list(directory.path) + .filter { !cancellation.get() } + .filter { identity -> pattern.isNullOrBlank() || windowsWildcardMatches(pattern, identity.path.substringAfterLast('/')) } + identities.forEach { identity -> knownIdentities[identity.path] = identity } + val placeholders = identities.map(::placeholder) + if (!cancellation.get()) api.completePlaceholderFetch(info, placeholders) + } catch (_: Throwable) { + if (!cancellation.get()) api.failPlaceholderFetch(info) + } finally { + cancelledRequests.remove(info.requestKey) + } + } + } + + override fun cancelFetchPlaceholders(info: WindowsCloudCallbackInfo) { + cancelledRequests[info.requestKey]?.set(true) + } + + override fun closed(info: WindowsCloudCallbackInfo, deleted: Boolean) { + if (deleted || info.fileIdentity == null) return + val identity = runCatching { requireIdentity(info, expectDirectory = null) }.getOrNull() ?: return + if (identity.directory) return + val localPath = root.resolve(identity.path.replace('/', File.separatorChar)).normalize() + if (!localPath.startsWith(root) || !Files.exists(localPath)) return + synchronized(namespaceMutationLock) { + if (api.placeholderState(localPath) != WindowsCloudPlaceholderState.Dirty) return + pendingWritebacks += identity.path + submitPathOperation(identity.path) { + val current = api.placeholderIdentity(localPath) + ?.let { encoded -> runCatching { WindowsCloudFileIdentityCodec.decode(encoded) }.getOrNull() } + ?.takeIf { it.accountId == backend.accountId && it.path == identity.path && !it.directory } + ?: knownIdentities[identity.path] + ?: identity + val uploaded = backend.upload(identity.path, localPath.toFile(), current.remoteRevision) + knownIdentities[uploaded.path] = uploaded + api.updatePlaceholder(localPath, placeholder(uploaded)) + api.markInSync(localPath) + } + } + } + + override fun deleteRequested(info: WindowsCloudCallbackInfo) { + executor.execute { + val accepted = runCatching { + val identity = requireIdentity(info, expectDirectory = null) + backend.delete(identity) + knownIdentities.remove(identity.path) + }.isSuccess + api.acknowledgeDelete(info, accepted) + } + } + + override fun renameRequested(info: WindowsCloudCallbackInfo, targetPath: String) { + executor.execute { + val accepted = synchronized(namespaceMutationLock) { + runCatching { + val identity = requireIdentity(info, expectDirectory = null) + val destination = relativePath(targetPath) + val destinationPath = root.resolve(destination.replace('/', File.separatorChar)).normalize() + require(!hasUncommittedChangeWithin(identity.path, destinationPath)) { + "The Windows placeholder cannot be renamed until its local changes are uploaded." + } + val moved = backend.move(identity, destination) + knownIdentities.remove(identity.path) + knownIdentities[moved.path] = moved + api.updatePlaceholder(destinationPath, placeholder(moved), preserveSyncState = true) + if (identity.directory) rebindMovedDescendants(identity, moved, destinationPath) + }.isSuccess + } + api.acknowledgeRename(info, accepted) + } + } + + /** Handles local files or complete directory trees that do not have Cloud Files identities yet. */ + fun localEntryChanged(path: Path) { + val normalized = path.toAbsolutePath().normalize() + if (!normalized.startsWith(root.toAbsolutePath().normalize()) || normalized == root) return + if (!Files.exists(normalized) || api.placeholderState(normalized) != WindowsCloudPlaceholderState.Absent) return + val relative = root.toAbsolutePath().normalize().relativize(normalized) + .joinToString("/") { it.toString() }.windowsCloudPath() + submitPathOperation(relative) { + if (Files.isDirectory(normalized)) uploadLocalTree(normalized) else uploadLocalEntry(normalized, relative) + } + } + + fun freeUpSpace(requestedBytes: Long): Long { + require(requestedBytes >= 0L) + var freed = 0L + knownIdentities.values.asSequence() + .filter { !it.directory } + .filter { identity -> !api.isPinned(localPath(identity)) } + .sortedBy { identity -> api.lastAccessedAtEpochMillis(localPath(identity)) } + .forEach { identity -> + if (requestedBytes > 0L && freed >= requestedBytes) return@forEach + val path = localPath(identity) + if (path.startsWith(root) && api.placeholderState(path) == WindowsCloudPlaceholderState.InSync) { + val allocated = api.allocatedBytes(path) + api.dehydrate(path) + freed += allocated + } + } + return freed + } + + fun enforcePolicy(policy: VirtualFileCachePolicy, nowEpochMillis: Long = System.currentTimeMillis()): Long { + if (!policy.automaticCleanup) return 0L + val entries = knownIdentities.values.mapNotNull { identity -> + if (identity.directory) return@mapNotNull null + val path = localPath(identity) + val allocated = api.allocatedBytes(path) + if (allocated <= 0L) return@mapNotNull null + val accessed = api.lastAccessedAtEpochMillis(path).coerceAtLeast(0L) + VirtualFileCacheEntry( + key = FileOfflineKey(backend.accountId, identity.path), + remoteRevision = identity.remoteRevision, + localRevision = identity.remoteRevision, + sizeBytes = allocated, + cachedAtEpochMillis = accessed, + lastAccessedAtEpochMillis = accessed, + retention = if (api.isPinned(path)) VirtualFileRetention.Pinned else VirtualFileRetention.Automatic, + dirty = api.placeholderState(path) == WindowsCloudPlaceholderState.Dirty, + ) + } + val plan = planVirtualFileEviction( + entries = entries, + policy = policy, + availableFreeBytes = Files.getFileStore(root).usableSpace, + nowEpochMillis = nowEpochMillis, + ) + var freed = 0L + plan.evictions.forEach { eviction -> + val identity = knownIdentities[eviction.key.relativePath] ?: return@forEach + val path = localPath(identity) + if ( + !api.isPinned(path) && + api.placeholderState(path) == WindowsCloudPlaceholderState.InSync && + api.allocatedBytes(path) == eviction.sizeBytes + ) { + api.dehydrate(path) + freed += eviction.sizeBytes + } + } + return freed + } + + fun summary(): WindowsCloudFilesSummary { + var cached = 0L + var reclaimable = 0L + var pinned = 0L + var hydratedCount = 0 + var pinnedCount = 0 + knownIdentities.values.forEach { identity -> + if (identity.directory) return@forEach + val path = localPath(identity) + val allocated = api.allocatedBytes(path).coerceAtLeast(0L) + if (allocated > 0L) hydratedCount += 1 + cached += allocated + if (api.isPinned(path)) { + pinned += allocated + pinnedCount += 1 + } else if (api.placeholderState(path) == WindowsCloudPlaceholderState.InSync) { + reclaimable += allocated + } + } + return WindowsCloudFilesSummary( + cachedBytes = cached, + reclaimableBytes = reclaimable, + pinnedBytes = pinned, + hydratedFileCount = hydratedCount, + pinnedFileCount = pinnedCount, + availableFreeBytes = Files.getFileStore(root).usableSpace, + pendingWritebackCount = pendingWritebacks.size, + failedWritebackCount = failedWritebacks.size, + ) + } + + override fun close() { + val key = connection.getAndSet(0L) + if (key != 0L) runCatching { api.disconnect(key) } + cancelledRequests.values.forEach { it.set(true) } + cancelledRequests.clear() + runCatching { watchService?.close() } + watcherThread?.interrupt() + watcherThread = null + watchService = null + pendingLocalChanges.values.forEach { it.cancel(false) } + pendingLocalChanges.clear() + queuedPathOperations.clear() + localChangeScheduler.shutdownNow() + executor.shutdownNow() + api.close() + } + + private fun populateDirectory(relativePath: String, localDirectory: Path) { + val identities = backend.list(relativePath) + val missing = ArrayList() + identities.forEach { identity -> + val localPath = localDirectory.resolve(identity.path.substringAfterLast('/')) + when (api.placeholderState(localPath)) { + WindowsCloudPlaceholderState.Absent -> { + if (!Files.exists(localPath)) { + missing += placeholder(identity) + knownIdentities[identity.path] = identity + } + } + WindowsCloudPlaceholderState.InSync -> { + val previous = api.placeholderIdentity(localPath) + ?.let { encoded -> runCatching { WindowsCloudFileIdentityCodec.decode(encoded) }.getOrNull() } + val changed = previous == null || + previous.accountId != identity.accountId || + previous.path != identity.path || + previous.remoteRevision != identity.remoteRevision || + previous.size != identity.size || + previous.directory != identity.directory + api.updatePlaceholder( + localPath, + placeholder(identity), + invalidateContent = changed && !identity.directory, + ) + knownIdentities[identity.path] = identity + } + WindowsCloudPlaceholderState.Dirty -> { + val previous = api.placeholderIdentity(localPath) + ?.let { encoded -> runCatching { WindowsCloudFileIdentityCodec.decode(encoded) }.getOrNull() } + if (previous != null && previous.accountId == backend.accountId && previous.path == identity.path) { + knownIdentities[identity.path] = previous + } + } + } + } + api.createPlaceholders(localDirectory, missing) + } + + private fun requireIdentity( + info: WindowsCloudCallbackInfo, + expectDirectory: Boolean?, + ): WindowsCloudFileIdentity { + val bytes = requireNotNull(info.fileIdentity) { "The Cloud Files callback has no identity." } + val identity = WindowsCloudFileIdentityCodec.decode(bytes) + require(identity.accountId == backend.accountId) { "The Cloud Files callback belongs to another account." } + requireWindowsCloudCallbackPath(root, info.normalizedPath, identity.path) + if (expectDirectory != null) require(identity.directory == expectDirectory) + require(identity.size == info.fileSize || identity.directory) { "The Cloud Files callback size is stale." } + knownIdentities[identity.path] = identity + return identity + } + + private fun placeholder(identity: WindowsCloudFileIdentity): WindowsCloudPlaceholder = WindowsCloudPlaceholder( + name = identity.path.substringAfterLast('/').ifBlank { "Nextcloud Native" }, + identity = WindowsCloudFileIdentityCodec.encode(identity), + size = identity.size, + directory = identity.directory, + ) + + private fun localPath(identity: WindowsCloudFileIdentity): Path = + root.resolve(identity.path.replace('/', File.separatorChar)).normalize() + + private fun relativePath(absoluteTarget: String): String { + val target = Path.of(absoluteTarget).toAbsolutePath().normalize() + val absoluteRoot = root.toAbsolutePath().normalize() + require(target.startsWith(absoluteRoot) && target != absoluteRoot) + return absoluteRoot.relativize(target).joinToString("/") { it.toString() }.windowsCloudPath() + } + + private fun submitPathOperation(path: String, block: () -> Unit) { + failedWritebacks -= path + writebackAttempts.remove(path) + val shouldSchedule = synchronized(queuedPathOperations) { + queuedPathOperations[path] = block + pathOperations.add(path) + } + if (shouldSchedule) schedulePathOperationDrain(path) + } + + private fun schedulePathOperationDrain(path: String, delayMillis: Long = 0L) { + require(delayMillis >= 0L) + val drain = { + var failedOperation: (() -> Unit)? = null + try { + while (true) { + val next = synchronized(queuedPathOperations) { + queuedPathOperations.remove(path) + } ?: break + try { + next() + failedWritebacks -= path + writebackAttempts.remove(path) + } catch (_: Throwable) { + failedOperation = next + break + } + } + } finally { + var retryDelay: Long? = null + var rescheduleImmediately = false + val shouldReschedule = synchronized(queuedPathOperations) { + pathOperations.remove(path) + if (failedOperation != null) { + val attempt = writebackAttempts.merge(path, 1, Int::plus) ?: 1 + failedWritebacks += path + if (attempt < MAX_WINDOWS_WRITEBACK_ATTEMPTS) { + queuedPathOperations.putIfAbsent(path, requireNotNull(failedOperation)) + retryDelay = writebackRetryDelayMillis(attempt).coerceAtLeast(0L) + pathOperations.add(path) + } else if (queuedPathOperations.containsKey(path)) { + writebackAttempts.remove(path) + failedWritebacks -= path + rescheduleImmediately = pathOperations.add(path) + } + false + } else if (queuedPathOperations.containsKey(path)) { + pathOperations.add(path) + } else { + pendingWritebacks -= path + failedWritebacks -= path + writebackAttempts.remove(path) + false + } + } + if (shouldReschedule) schedulePathOperationDrain(path) + if (rescheduleImmediately) schedulePathOperationDrain(path) + retryDelay?.let { delay -> schedulePathOperationDrain(path, delay) } + } + } + if (delayMillis == 0L) { + runCatching { executor.execute(drain) } + } else { + runCatching { + localChangeScheduler.schedule( + { runCatching { executor.execute(drain) } }, + delayMillis, + TimeUnit.MILLISECONDS, + ) + } + } + } + + private fun startLocalWatcher() { + val watcher = root.fileSystem.newWatchService() + Files.walk(root).use { paths -> + paths.filter(Files::isDirectory).forEach { directory -> directory.registerForWindowsCloudChanges(watcher) } + } + watchService = watcher + watcherThread = Thread({ + while (!Thread.currentThread().isInterrupted) { + val key = try { + watcher.take() + } catch (_: InterruptedException) { + return@Thread + } catch (_: Throwable) { + return@Thread + } + val directory = key.watchable() as? Path + if (directory != null) { + key.pollEvents().forEach { event -> + if (event.kind() == StandardWatchEventKinds.OVERFLOW) return@forEach + val child = directory.resolve(event.context() as Path).toAbsolutePath().normalize() + if ( + event.kind() == StandardWatchEventKinds.ENTRY_CREATE && + Files.isDirectory(child) && + !Files.isSymbolicLink(child) + ) { + runCatching { + Files.walk(child).use { descendants -> + descendants.filter { path -> Files.isDirectory(path) && !Files.isSymbolicLink(path) } + .forEach { descendant -> descendant.registerForWindowsCloudChanges(watcher) } + } + } + } + if (event.kind() != StandardWatchEventKinds.ENTRY_DELETE) scheduleLocalChange(child) + } + } + if (!key.reset()) continue + } + }, "nextcloud-windows-cloud-files-watcher").apply { + isDaemon = true + start() + } + } + + private fun scheduleLocalChange(path: Path) { + pendingLocalChanges.remove(path)?.cancel(false) + pendingLocalChanges[path] = localChangeScheduler.schedule( + { + pendingLocalChanges.remove(path) + runCatching { localEntryChanged(path) } + }, + LOCAL_CHANGE_SETTLE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + + private fun recoverLocalChanges() { + recoverLocalPlaceholders() + val pendingDirectories = ArrayDeque() + pendingDirectories += "" + var discovered = 0 + while (pendingDirectories.isNotEmpty() && discovered < MAX_RECOVERY_IDENTITIES) { + val directory = pendingDirectories.removeFirst() + val children = runCatching { backend.list(directory) }.getOrElse { emptyList() } + children.forEach { identity -> + knownIdentities[identity.path] = identity + discovered += 1 + if (identity.directory) pendingDirectories += identity.path + } + } + runCatching { + val unmanaged = Files.walk(root).use { paths -> + paths.filter { path -> path != root && Files.exists(path) } + .filter { path -> api.placeholderState(path) == WindowsCloudPlaceholderState.Absent } + .sorted(compareBy { it.nameCount }) + .toList() + } + unmanaged.forEach { path -> + val relative = root.toAbsolutePath().normalize().relativize(path.toAbsolutePath().normalize()) + .joinToString("/") { it.toString() }.windowsCloudPath() + runCatching { uploadLocalEntry(path, relative) } + } + } + } + + private fun recoverLocalPlaceholders() { + runCatching { + Files.walk(root).use { paths -> + paths.filter { path -> path != root && !Files.isSymbolicLink(path) }.forEach { local -> + val state = api.placeholderState(local) + if (state == WindowsCloudPlaceholderState.Absent) return@forEach + val directory = Files.isDirectory(local, LinkOption.NOFOLLOW_LINKS) + if (!directory && !Files.isRegularFile(local, LinkOption.NOFOLLOW_LINKS)) return@forEach + val original = api.placeholderIdentity(local) + ?.let { encoded -> runCatching { WindowsCloudFileIdentityCodec.decode(encoded) }.getOrNull() } + ?.takeIf { identity -> + identity.accountId == backend.accountId && + identity.directory == directory && + localPath(identity).toAbsolutePath().normalize() == local.toAbsolutePath().normalize() + } + ?: return@forEach + knownIdentities[original.path] = original + if (state != WindowsCloudPlaceholderState.Dirty || original.directory) return@forEach + pendingWritebacks += original.path + submitPathOperation(original.path) { + val current = requireNotNull(api.placeholderIdentity(local)) { + "The dirty Windows placeholder has no recoverable identity." + }.let(WindowsCloudFileIdentityCodec::decode) + require( + current.accountId == backend.accountId && + current.path == original.path && + !current.directory, + ) { "The dirty Windows placeholder identity is not safe to recover." } + val uploaded = backend.upload(current.path, local.toFile(), current.remoteRevision) + knownIdentities[uploaded.path] = uploaded + api.updatePlaceholder(local, placeholder(uploaded)) + api.markInSync(local) + } + } + } + } + } + + private fun rebindMovedDescendants( + originalDirectory: WindowsCloudFileIdentity, + movedDirectory: WindowsCloudFileIdentity, + localDirectory: Path, + ) { + if (!Files.isDirectory(localDirectory) || Files.isSymbolicLink(localDirectory)) return + Files.walk(localDirectory).use { paths -> + paths.filter { path -> path != localDirectory && !Files.isSymbolicLink(path) } + .forEach { descendant -> + val suffix = localDirectory.relativize(descendant).joinToString("/") { it.toString() }.windowsCloudPath() + val expectedOriginalPath = "${originalDirectory.path}/$suffix" + val previous = api.placeholderIdentity(descendant) + ?.let { encoded -> runCatching { WindowsCloudFileIdentityCodec.decode(encoded) }.getOrNull() } + ?.takeIf { identity -> + identity.accountId == backend.accountId && identity.path == expectedOriginalPath + } + ?: return@forEach + val rebound = previous.copy(path = "${movedDirectory.path}/$suffix") + knownIdentities.remove(previous.path) + knownIdentities[rebound.path] = rebound + api.updatePlaceholder( + descendant, + placeholder(rebound), + preserveSyncState = true, + ) + } + } + } + + private fun hasUncommittedChangeWithin(sourcePath: String, localDestination: Path): Boolean { + val pending = sequenceOf( + pendingWritebacks.asSequence(), + failedWritebacks.asSequence(), + pathOperations.asSequence(), + synchronized(queuedPathOperations) { queuedPathOperations.keys.toList().asSequence() }, + ).flatten().any { path -> path == sourcePath || path.startsWith("$sourcePath/") } + if (pending) return true + if (!Files.exists(localDestination, LinkOption.NOFOLLOW_LINKS)) return false + if (!Files.isDirectory(localDestination, LinkOption.NOFOLLOW_LINKS)) { + return api.placeholderState(localDestination) == WindowsCloudPlaceholderState.Dirty + } + return Files.walk(localDestination).use { paths -> + paths.anyMatch { path -> + !Files.isSymbolicLink(path) && api.placeholderState(path) == WindowsCloudPlaceholderState.Dirty + } + } + } + + private fun uploadLocalEntry(localPath: Path, relativePath: String) { + require(localPath.toAbsolutePath().normalize().startsWith(root.toAbsolutePath().normalize())) + require(!Files.isSymbolicLink(localPath)) { "Windows Cloud Files does not import symbolic links." } + require(Files.isDirectory(localPath) || Files.isRegularFile(localPath)) { + "Windows Cloud Files only imports regular files and folders." + } + pendingWritebacks += relativePath + var completed = false + try { + val uploaded = try { + if (Files.isDirectory(localPath)) { + backend.createDirectory(relativePath) + } else { + backend.upload(relativePath, localPath.toFile(), expectedRemoteRevision = null) + } + } catch (failure: Throwable) { + val reconciled = runCatching { backend.resolve(relativePath) }.getOrNull() + ?.takeIf { identity -> localEntryMatches(identity, localPath, relativePath) } + if (reconciled == null) throw failure + reconciled + } + knownIdentities[relativePath] = uploaded + api.convertToPlaceholder(localPath, placeholder(uploaded)) + api.markInSync(localPath) + completed = true + } finally { + if (completed) pendingWritebacks -= relativePath + } + } + + private fun uploadLocalTree(localDirectory: Path) { + val absoluteRoot = root.toAbsolutePath().normalize() + val normalizedDirectory = localDirectory.toAbsolutePath().normalize() + require(normalizedDirectory.startsWith(absoluteRoot) && normalizedDirectory != absoluteRoot) + val entries = Files.walk(normalizedDirectory).use { paths -> + paths.limit(MAX_RECOVERY_IDENTITIES.toLong() + 1L) + .sorted(compareBy { it.nameCount }) + .toList() + } + require(entries.size <= MAX_RECOVERY_IDENTITIES) { + "The new Windows folder contains too many entries to import safely." + } + entries.forEach { entry -> + val normalized = entry.toAbsolutePath().normalize() + require(normalized.startsWith(normalizedDirectory) && !Files.isSymbolicLink(normalized)) { + "The new Windows folder contains an unsafe entry." + } + if (Files.exists(normalized) && api.placeholderState(normalized) == WindowsCloudPlaceholderState.Absent) { + val relative = absoluteRoot.relativize(normalized) + .joinToString("/") { it.toString() }.windowsCloudPath() + uploadLocalEntry(normalized, relative) + } + } + } + + private fun localEntryMatches( + identity: WindowsCloudFileIdentity, + localPath: Path, + relativePath: String, + ): Boolean { + if (identity.accountId != backend.accountId || identity.path != relativePath) return false + val localDirectory = Files.isDirectory(localPath) + if (identity.directory != localDirectory) return false + if (localDirectory) return true + if (!Files.isRegularFile(localPath) || identity.size != Files.size(localPath)) return false + if (identity.size == 0L) return true + return runCatching { + backend.open(identity).use remoteUse@ { remote -> + if (remote.size != identity.size) return@remoteUse false + Files.newInputStream(localPath).buffered().use localUse@ { local -> + var offset = 0L + val localBuffer = ByteArray(RECONCILIATION_CHUNK_BYTES) + while (offset < identity.size) { + val length = minOf(localBuffer.size.toLong(), identity.size - offset).toInt() + var localCount = 0 + while (localCount < length) { + val read = local.read(localBuffer, localCount, length - localCount) + if (read < 0) return@localUse false + localCount += read + } + val remoteBytes = remote.read(offset, length) + if (remoteBytes.size != length || !remoteBytes.contentEquals(localBuffer.copyOf(length))) { + return@localUse false + } + offset += length + } + local.read() == -1 + } + } + }.getOrDefault(false) + } + + private companion object { + const val LOCAL_CHANGE_SETTLE_MILLIS = 750L + const val MAX_RECOVERY_IDENTITIES = 20_000 + const val RECONCILIATION_CHUNK_BYTES = 1024 * 1024 + } +} + +private class AtomicLongState { + @Volatile private var value: Long = 0L + @Synchronized fun get(): Long = value + @Synchronized fun set(next: Long) { value = next } + @Synchronized fun getAndSet(next: Long): Long = value.also { value = next } +} + +internal fun requireWindowsCloudCallbackPath(root: Path, normalizedPath: String, identityPath: String) { + val absoluteRoot = root.toAbsolutePath().normalize() + val callbackTarget = Path.of(normalizedPath).toAbsolutePath().normalize() + require(callbackTarget.startsWith(absoluteRoot)) { "The Cloud Files callback escaped its sync root." } + val relative = if (callbackTarget == absoluteRoot) { + "" + } else { + absoluteRoot.relativize(callbackTarget).joinToString("/") { it.toString() }.windowsCloudPath() + } + require(relative == identityPath) { "The Cloud Files callback path does not match its identity." } +} + +private fun windowsWildcardMatches(pattern: String, name: String): Boolean { + if (pattern == "*" || pattern == "*.*") return true + var patternIndex = 0 + var nameIndex = 0 + var starIndex = -1 + var retryNameIndex = -1 + while (nameIndex < name.length) { + if (patternIndex < pattern.length && (pattern[patternIndex] == '?' || pattern[patternIndex].equals(name[nameIndex], true))) { + patternIndex += 1 + nameIndex += 1 + } else if (patternIndex < pattern.length && pattern[patternIndex] == '*') { + starIndex = patternIndex++ + retryNameIndex = nameIndex + } else if (starIndex >= 0) { + patternIndex = starIndex + 1 + nameIndex = ++retryNameIndex + } else { + return false + } + } + while (patternIndex < pattern.length && pattern[patternIndex] == '*') patternIndex += 1 + return patternIndex == pattern.length +} + +private fun String.windowsCloudPath(): String { + val normalized = trim('/', '\\').replace('\\', '/') + if (normalized.isEmpty()) return "" + require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) + require('\u0000' !in normalized) + return normalized +} + +private const val WINDOWS_CLOUD_ALIGNMENT = 4 * 1024L +private const val MAX_WINDOWS_WRITEBACK_ATTEMPTS = 5 + +private fun windowsWritebackRetryDelayMillis(attempt: Int): Long { + require(attempt in 1 until MAX_WINDOWS_WRITEBACK_ATTEMPTS) + return (250L shl (attempt - 1)).coerceAtMost(30_000L) +} + +private fun Path.registerForWindowsCloudChanges(watcher: WatchService) { + register( + watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + StandardWatchEventKinds.ENTRY_DELETE, + ) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt new file mode 100644 index 000000000..b558f891f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt @@ -0,0 +1,101 @@ +package dev.obiente.nextcloudnative.nativeui.preview + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayActivity +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayActivityPhase +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayPhase +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayPopup +import dev.obiente.nextcloudnative.app.DesktopFileSyncTraySnapshot +import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme +import java.awt.Robot +import java.io.File +import javax.imageio.ImageIO +import kotlinx.coroutines.delay + +/** + * Network-free visual QA for the custom desktop tray popup. + * + * The activity, account, paths, and progress are synthetic. The exact production composable is + * rendered in a real desktop window so host-shell menu theming cannot affect the result. + */ +fun main() = application { + val outputPath = requireNotNull(System.getenv("NEXTCLOUD_NATIVE_TRAY_QA_OUTPUT")) { + "NEXTCLOUD_NATIVE_TRAY_QA_OUTPUT must name the screenshot destination." + } + val snapshot = DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.Syncing, + pairCount = 4, + pendingCount = 17, + conflictCount = 1, + failedCount = 1, + message = "Uploading DSF10428.RAF", + accountLabel = "alex@example.invalid", + overallProgress = 0.42f, + lastCheckedEpochMillis = 1_787_526_720_000L, + activities = listOf( + DesktopFileSyncTrayActivity( + stableId = "photos:42", + relativePath = "2026/Summer festival/Friday/DSF10428.RAF", + pairLabel = "Camera originals to /Photos/Events", + phase = DesktopFileSyncTrayActivityPhase.Uploading, + sizeBytes = 52_848_640L, + detail = "8 of 17", + ), + DesktopFileSyncTrayActivity( + stableId = "projects:18", + relativePath = "Campaign/Launch edit/project.kdenlive", + pairLabel = "Creative projects to /Projects", + phase = DesktopFileSyncTrayActivityPhase.Completed, + sizeBytes = 1_835_008L, + detail = "Synced safely", + ), + DesktopFileSyncTrayActivity( + stableId = "archive:9", + relativePath = "Documents/2026/Invoice-021.pdf", + pairLabel = "Documents to /Administration", + phase = DesktopFileSyncTrayActivityPhase.Conflict, + sizeBytes = 384_124L, + detail = "Choose a version", + ), + DesktopFileSyncTrayActivity( + stableId = "photos:43", + relativePath = "2026/Summer festival/Friday/DSF10428.JPG", + pairLabel = "Camera originals to /Photos/Events", + phase = DesktopFileSyncTrayActivityPhase.Waiting, + sizeBytes = 12_443_648L, + detail = "RAW files first", + ), + ), + ) + + Window( + onCloseRequest = ::exitApplication, + title = "Nextcloud Native tray QA", + state = rememberWindowState(width = 430.dp, height = 560.dp), + undecorated = true, + transparent = true, + resizable = false, + ) { + NextcloudNativeTheme(darkTheme = false) { + DesktopFileSyncTrayPopup( + snapshot = snapshot, + onOpenApp = {}, + onSyncNow = {}, + onTogglePaused = {}, + onQuit = {}, + ) + } + LaunchedEffect(outputPath) { + delay(1_500) + val output = File(outputPath) + output.parentFile?.mkdirs() + ImageIO.write(Robot().createScreenCapture(window.bounds), "png", output) + delay(250) + exitApplication() + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt index 49aa6af83..5d1f40334 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt @@ -5,19 +5,39 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.painterResource import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import androidx.compose.ui.unit.dp import dev.obiente.nextcloudnative.app.DesktopNextcloudServices +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayPhase +import dev.obiente.nextcloudnative.app.DesktopFileSyncTrayPopup +import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult import dev.obiente.nextcloudnative.app.NextcloudNativeApp import dev.obiente.nextcloudnative.app.ThemePreference +import dev.obiente.nextcloudnative.app.tooltip +import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme import dev.obiente.nextcloudnative.app.design.NextcloudPresentation +import java.awt.SystemTray +import java.awt.TrayIcon +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import java.awt.event.WindowAdapter +import java.awt.event.WindowEvent +import javax.imageio.ImageIO +import kotlinx.coroutines.launch fun main() = application { val themePreference = remember { mutableStateOf(ThemePreference.System) } @@ -32,10 +52,130 @@ fun main() = application { ThemePreference.Dark -> true } val background = if (darkTheme) DarkWindowBackground else LightWindowBackground + val scope = rememberCoroutineScope() + val traySnapshot = services.fileSyncTraySnapshot.collectAsState().value + val systemTraySupported = remember { SystemTray.isSupported() } + val trayAvailable = remember { mutableStateOf(false) } + val windowVisible = remember { mutableStateOf(true) } + val trayPopupVisible = remember { mutableStateOf(false) } + val appIcon = painterResource("nextcloud-native.png") + val desktopTrayIcon = remember(systemTraySupported) { + if (!systemTraySupported) { + null + } else { + runCatching { + val resource = requireNotNull( + Thread.currentThread().contextClassLoader.getResource("nextcloud-native.png"), + ) + TrayIcon(ImageIO.read(resource), traySnapshot.tooltip()).apply { + isImageAutoSize = true + } + }.getOrNull() + } + } + + LaunchedEffect(services) { + runCatching { services.refreshFileSyncTraySnapshot() } + runCatching { services.restoreVirtualFileProviderIfEnabled() } + services.startDesktopSyncLifecycle() + } + DisposableEffect(services) { + onDispose(services::close) + } + + DisposableEffect(desktopTrayIcon) { + if (desktopTrayIcon == null) return@DisposableEffect onDispose {} + val clickListener = object : MouseAdapter() { + override fun mouseReleased(event: MouseEvent) { + if ( + event.button == MouseEvent.BUTTON1 || + event.button == MouseEvent.BUTTON3 || + event.isPopupTrigger + ) { + scope.launch { trayPopupVisible.value = !trayPopupVisible.value } + } + } + } + desktopTrayIcon.addMouseListener(clickListener) + val installed = runCatching { + SystemTray.getSystemTray().add(desktopTrayIcon) + true + }.getOrDefault(false) + trayAvailable.value = installed + onDispose { + trayAvailable.value = false + desktopTrayIcon.removeMouseListener(clickListener) + if (installed) SystemTray.getSystemTray().remove(desktopTrayIcon) + } + } + SideEffect { + desktopTrayIcon?.toolTip = traySnapshot.tooltip() + } + + if (trayAvailable.value && trayPopupVisible.value) { + Window( + onCloseRequest = { trayPopupVisible.value = false }, + title = "Nextcloud Native sync activity", + icon = appIcon, + state = rememberWindowState( + position = WindowPosition(Alignment.BottomEnd), + width = 430.dp, + height = 560.dp, + ), + undecorated = true, + transparent = true, + resizable = false, + alwaysOnTop = true, + ) { + DisposableEffect(window) { + val focusListener = object : WindowAdapter() { + override fun windowLostFocus(event: WindowEvent?) { + trayPopupVisible.value = false + } + } + window.addWindowFocusListener(focusListener) + window.requestFocus() + onDispose { window.removeWindowFocusListener(focusListener) } + } + NextcloudNativeTheme(darkTheme = darkTheme) { + DesktopFileSyncTrayPopup( + snapshot = traySnapshot, + onOpenApp = { + trayPopupVisible.value = false + windowVisible.value = true + }, + onSyncNow = { + scope.launch { + val result = services.syncAllFileSyncPairsFromTray() + desktopTrayIcon?.displayMessage( + "Folder sync", + result.trayMessage(), + if (result is FileSyncCenterActionResult.Rejected) { + TrayIcon.MessageType.ERROR + } else { + TrayIcon.MessageType.INFO + }, + ) + } + }, + onTogglePaused = { + services.setFileSyncPaused( + traySnapshot.phase != DesktopFileSyncTrayPhase.Paused, + ) + }, + onQuit = ::exitApplication, + ) + } + } + } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + if (trayAvailable.value) windowVisible.value = false else exitApplication() + }, + visible = windowVisible.value, title = "Nextcloud Native", + icon = appIcon, state = rememberWindowState(width = 1_280.dp, height = 820.dp), ) { SideEffect { @@ -51,5 +191,11 @@ fun main() = application { } } +private fun FileSyncCenterActionResult.trayMessage(): String = when (this) { + is FileSyncCenterActionResult.Completed -> message + is FileSyncCenterActionResult.Rejected -> reason + is FileSyncCenterActionResult.Unsupported -> reason +} + private val DarkWindowBackground = Color(0xFF0D0F13) private val LightWindowBackground = Color(0xFFF7F6FA) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt index 96e846d06..ea2a4a935 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt @@ -27,7 +27,10 @@ private val pngSignature = byteArrayOf( 0x0a, ) -internal val preservedMarketingCaptureFiles: Set = emptySet() +internal val preservedMarketingCaptureFiles: Set = setOf( + // Captured by the native X11 tray harness rather than the headless Compose scene renderer. + "file-sync-tray-linux.png", +) internal fun declaredCaptureFiles(manifestPath: Path): Set { if (!Files.exists(manifestPath, LinkOption.NOFOLLOW_LINKS)) return emptySet() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NetworkInertMarketingServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NetworkInertMarketingServices.kt index 823b74449..fdbc4ece9 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NetworkInertMarketingServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NetworkInertMarketingServices.kt @@ -1,6 +1,9 @@ package dev.obiente.nextcloudnative.nativeui.preview import dev.obiente.nextcloudnative.app.NextcloudPlatformServices +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudFileListing +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource import java.lang.reflect.InvocationHandler import java.lang.reflect.Proxy @@ -14,6 +17,7 @@ internal fun networkInertMarketingServices( "hashCode" -> System.identityHashCode(proxy) "equals" -> proxy === arguments?.singleOrNull() "loadPreview" -> previewBytes.copyOf() + "listFilesWithSource" -> marketingFileSyncListing(arguments?.getOrNull(2) as? String ?: "") else -> error("The network-inert marketing fixture rejected ${method.name}.") } } @@ -23,3 +27,31 @@ internal fun networkInertMarketingServices( handler, ) as NextcloudPlatformServices } + +private fun marketingFileSyncListing(path: String): NextcloudFileListing { + val files = when (path) { + "Photos/Studio" -> listOf( + marketingFile("Photos/Studio/RAW", directory = true), + marketingFile("Photos/Studio/Exports", directory = true), + marketingFile("Photos/Studio/brief.pdf", directory = false), + ) + "Photos/Studio/RAW" -> listOf( + marketingFile("Photos/Studio/RAW/Day 1", directory = true), + marketingFile("Photos/Studio/RAW/Day 2", directory = true), + ) + else -> emptyList() + } + return NextcloudFileListing(files, NextcloudFileListingSource.Network) +} + +private fun marketingFile(path: String, directory: Boolean): NextcloudFile = NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = directory, + mimeType = if (directory) null else "application/pdf", + size = if (directory) null else 2_400_000L, + lastModified = "2026-07-28T10:00:00Z", + fileId = path.hashCode().toLong().let { if (it < 0L) -it else it }, + hasPreview = false, + etag = "\"fixture-${path.length}\"", +) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngineTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngineTest.kt new file mode 100644 index 000000000..3a35363f9 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngineTest.kt @@ -0,0 +1,38 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFileSyncEngineTest { + @Test + fun `stale owned stages are reclaimed without touching lookalikes`() { + val root = Files.createTempDirectory("desktop-sync-stage-recovery-").toFile() + try { + val stale = root.resolve("nextcloud-native-download-${UUID.randomUUID()}.tmp").apply { + writeText("partial download") + } + val unknownPrefix = root.resolve("nextcloud-native-preview-${UUID.randomUUID()}.tmp").apply { + writeText("keep") + } + val invalidToken = root.resolve("nextcloud-native-download-not-a-uuid.tmp").apply { + writeText("keep") + } + val ownedDirectory = root.resolve("nextcloud-native-download-${UUID.randomUUID()}.tmp").apply { + mkdir() + } + + assertEquals(1, reclaimDesktopFileSyncStages(root)) + + assertFalse(stale.exists()) + assertTrue(unknownPrefix.isFile) + assertTrue(invalidToken.isFile) + assertTrue(ownedDirectory.isDirectory) + } finally { + root.deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTreeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTreeTest.kt new file mode 100644 index 000000000..1f3a710d3 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncLocalTreeTest.kt @@ -0,0 +1,282 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.nio.file.attribute.FileTime +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFileSyncLocalTreeTest { + @Test + fun `scan prunes ignored trees and retains selective parents`() { + val root = Files.createTempDirectory("desktop-sync-local-") + try { + root.resolve("Photos/Keep").createDirectories() + root.resolve("Photos/Ignore/cache").createDirectories() + root.resolve("Photos/Keep/a.RAF").writeText("raw") + root.resolve("Photos/Keep/a.jpg").writeText("jpeg") + root.resolve("Photos/Ignore/cache/private.jpg").writeText("ignored") + val configuration = FileSyncConfiguration( + deviceLabel = "Desktop", + selectedPaths = listOf("Photos/Keep"), + ignoredPatterns = listOf("Photos/Ignore"), + ) + + val entries = DesktopFileSyncLocalTree(root.toFile()).scan { path, kind -> + configuration.includesSyncPath(path, kind) + }.map { it.entry.relativePath } + + assertEquals(listOf("Photos", "Photos/Keep", "Photos/Keep/a.RAF", "Photos/Keep/a.jpg"), entries) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `replacement is revision guarded and publishes only complete bytes`() { + val root = Files.createTempDirectory("desktop-sync-write-") + val source = Files.createTempFile("desktop-sync-source-", ".tmp") + try { + root.resolve("Notes").createDirectories() + root.resolve("Notes/today.md").writeText("old") + source.writeText("complete replacement") + val tree = DesktopFileSyncLocalTree(root.toFile()) + val before = requireNotNull(tree.resolve("Notes/today.md")) + + tree.writeFile("Notes/today.md", source.toFile(), before.entry.revision) + + assertEquals("complete replacement", root.resolve("Notes/today.md").toFile().readText()) + assertFalse(root.toFile().walkTopDown().any { ".nextcloud-native-" in it.name }) + } finally { + root.toFile().deleteRecursively() + Files.deleteIfExists(source) + } + } + + @Test + fun `scan restores an owned backup after interrupted replacement`() { + val root = Files.createTempDirectory("desktop-sync-recover-") + try { + root.resolve("Notes").createDirectories() + root.resolve("Notes/.today.md.nextcloud-native-backup-4d6f8828-7d52-4f2d-945b-f46aa4c97b41") + .writeText("protected") + root.resolve("Notes/.draft.md.nextcloud-native-download-801e8c87-592d-4d1d-9d77-61383e22bd3a") + .writeText("partial") + + DesktopFileSyncLocalTree(root.toFile()).scan() + + assertEquals("protected", root.resolve("Notes/today.md").toFile().readText()) + assertFalse( + Files.exists( + root.resolve("Notes/.draft.md.nextcloud-native-download-801e8c87-592d-4d1d-9d77-61383e22bd3a"), + ), + ) + assertTrue(Files.exists(root.resolve("Notes"))) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `marker like user files are preserved unless they carry an owned uuid suffix`() { + val root = Files.createTempDirectory("desktop-sync-owned-stage-") + try { + root.resolve(".notes.nextcloud-native-download-archive").writeText("keep") + root.resolve(".notes.nextcloud-native-backup-personal").writeText("keep too") + + val entries = DesktopFileSyncLocalTree(root.toFile()).scan().map { it.entry.relativePath } + + assertTrue(".notes.nextcloud-native-download-archive" in entries) + assertTrue(".notes.nextcloud-native-backup-personal" in entries) + assertTrue(Files.exists(root.resolve(".notes.nextcloud-native-download-archive"))) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `scan reclaims the backup after a completed replacement`() { + val root = Files.createTempDirectory("desktop-sync-visible-backup-") + try { + root.resolve("notes.txt").writeText("published") + val backup = ".notes.txt.nextcloud-native-backup-0b88c03f-55d1-4ccb-b92e-aa8ee32caf65" + root.resolve(backup).writeText("protected original") + + val entries = DesktopFileSyncLocalTree(root.toFile()).scan().map { it.entry.relativePath } + + assertTrue("notes.txt" in entries) + assertFalse(backup in entries) + assertFalse(Files.exists(root.resolve(backup))) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `ambiguous replacement artifacts are bounded by the next recovery scan`() { + val root = Files.createTempDirectory("desktop-sync-bounded-backup-") + val token = "00000000-0000-4000-8000-000000000001" + try { + root.resolve("notes.txt").writeText("published") + val backup = root.resolve(".notes.txt.nextcloud-native-backup-$token").apply { + writeText("protected original") + } + val download = root.resolve(".notes.txt.nextcloud-native-download-$token").apply { + writeText("complete replacement") + } + + DesktopFileSyncLocalTree(root.toFile()).scan() + DesktopFileSyncLocalTree(root.toFile()).scan() + + assertFalse(Files.exists(backup)) + assertFalse(Files.exists(download)) + assertEquals("published", root.resolve("notes.txt").toFile().readText()) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `same size edits with a preserved timestamp change the local revision`() { + val root = Files.createTempDirectory("desktop-sync-content-revision-") + try { + val file = root.resolve("notes.txt") + file.writeText("first") + val fixedTime = FileTime.fromMillis(1_700_000_000_000L) + Files.setLastModifiedTime(file, fixedTime) + val tree = DesktopFileSyncLocalTree(root.toFile()) + val before = requireNotNull(tree.resolve("notes.txt")).entry + + file.writeText("later") + Files.setLastModifiedTime(file, fixedTime) + val after = requireNotNull(tree.resolve("notes.txt")).entry + + assertTrue(before.revision != after.revision) + assertTrue(before.contentHash != after.contentHash) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `unchanged files reuse the persisted digest when change metadata is stable`() { + val root = Files.createTempDirectory("desktop-sync-digest-cache-") + try { + root.resolve("notes.txt").writeText("unchanged") + var digestCount = 0 + val tree = DesktopFileSyncLocalTree( + root.toFile(), + changeTokenProvider = { "stable-change-token" }, + ) { + digestCount += 1 + "a".repeat(64) + } + val first = tree.scan() + val cachedRevisions = first.associate { document -> + document.entry.relativePath to document.entry.revision + } + + val second = tree.scan(cachedRevisions) + + assertEquals(first.map { it.entry }, second.map { it.entry }) + assertEquals(1, digestCount) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `missing stable change metadata forces content to be rehashed`() { + val root = Files.createTempDirectory("desktop-sync-digest-fail-closed-") + try { + root.resolve("notes.txt").writeText("unchanged") + var digestCount = 0 + val tree = DesktopFileSyncLocalTree( + root.toFile(), + changeTokenProvider = { null }, + ) { + digestCount += 1 + "b".repeat(64) + } + val first = tree.scan() + + tree.scan(first.associate { it.entry.relativePath to it.entry.revision }) + + assertEquals(2, digestCount) + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `operations reject an ancestor replaced by a symlink after scan`() { + val root = Files.createTempDirectory("desktop-sync-symlink-race-") + val outside = Files.createTempDirectory("desktop-sync-outside-") + val staged = Files.createTempFile("desktop-sync-upload-", ".tmp").toFile() + try { + root.resolve("Notes").createDirectories() + root.resolve("Notes/today.md").writeText("inside") + outside.resolve("today.md").writeText("outside") + val tree = DesktopFileSyncLocalTree(root.toFile()) + val scanned = tree.scan().single { it.entry.relativePath == "Notes/today.md" } + Files.move(root.resolve("Notes"), root.resolve("Notes-original")) + val linked = runCatching { Files.createSymbolicLink(root.resolve("Notes"), outside) }.isSuccess + if (!linked) return + + assertFailsWith { + tree.stageForUpload("Notes/today.md", staged, maximumBytes = 1024L) + } + assertFailsWith { + tree.delete("Notes/today.md", scanned.entry.revision) + } + assertEquals("outside", outside.resolve("today.md").toFile().readText()) + } finally { + staged.delete() + root.toFile().deleteRecursively() + outside.toFile().deleteRecursively() + } + } + + @Test + fun `directory to file replacement protects the original until complete bytes are published`() { + val root = Files.createTempDirectory("desktop-sync-type-replace-") + val source = Files.createTempFile("desktop-sync-source-", ".tmp") + try { + root.resolve("Notes/today.md/child.txt").parent.createDirectories() + root.resolve("Notes/today.md/child.txt").writeText("protected") + source.writeText("replacement file") + val tree = DesktopFileSyncLocalTree(root.toFile()) + val before = requireNotNull(tree.resolve("Notes/today.md")) + + tree.replaceWithFile("Notes/today.md", source.toFile(), before.entry.revision) + + assertEquals("replacement file", root.resolve("Notes/today.md").toFile().readText()) + assertFalse(root.toFile().walkTopDown().any { ".nextcloud-native-" in it.name }) + } finally { + root.toFile().deleteRecursively() + Files.deleteIfExists(source) + } + } + + @Test + fun `file to directory replacement keeps a recoverable backup until publication`() { + val root = Files.createTempDirectory("desktop-sync-directory-replace-") + try { + root.resolve("Albums").createDirectories() + root.resolve("Albums/Shared").writeText("protected") + val tree = DesktopFileSyncLocalTree(root.toFile()) + val before = requireNotNull(tree.resolve("Albums/Shared")) + + tree.replaceWithDirectory("Albums/Shared", before.entry.revision) + + assertTrue(Files.isDirectory(root.resolve("Albums/Shared"))) + assertFalse(root.toFile().walkTopDown().any { ".nextcloud-native-" in it.name }) + } finally { + root.toFile().deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTreeTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTreeTest.kt new file mode 100644 index 000000000..08bed2d37 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTreeTest.kt @@ -0,0 +1,87 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails + +class DesktopFileSyncRemoteTreeTest { + @Test + fun `dav parser preserves plus signs and reads guarded revisions`() { + val documents = parseDesktopSyncDav( + """ + + + + /remote.php/dav/files/alice/Photos/July%20%2B%20August/ + + July + August + "directory-etag" + + + + + /remote.php/dav/files/alice/Photos/July%20%2B%20August/a.RAF + + "file-etag" + 42 + + + + + """.trimIndent().encodeToByteArray(), + userId = "alice", + ) + + assertEquals( + listOf("Photos/July + August", "Photos/July + August/a.RAF"), + documents.map { it.entry.relativePath }, + ) + assertEquals(SyncEntryKind.Directory, documents.first().entry.kind) + assertEquals(42L, documents.last().entry.size) + assertEquals("\"file-etag\"", documents.last().entry.etag) + } + + @Test + fun `dav parser rejects external entities`() { + assertFails { + parseDesktopSyncDav( + """ + + ]> + &xxe; + """.trimIndent().encodeToByteArray(), + userId = "alice", + ) + } + } + + @Test + fun `only exact provider owned upload stages are suppressed`() { + assertEquals( + true, + isDesktopOwnedUploadStage("Photos/.nextcloud-native-123e4567-e89b-12d3-a456-426614174000.upload"), + ) + assertEquals(false, isDesktopOwnedUploadStage("Photos/.nextcloud-native-not-a-uuid.upload")) + assertEquals(false, isDesktopOwnedUploadStage("Photos/user-upload.upload")) + } + + @Test + fun `only exact provider owned replacement backups reveal a recovery destination`() { + assertEquals( + "Photos/today.md", + desktopOwnedBackupDestination( + "Photos/.today.md.nextcloud-native-backup-123e4567-e89b-12d3-a456-426614174000", + ), + ) + assertEquals(null, desktopOwnedBackupDestination("Photos/.today.md.nextcloud-native-backup-not-a-uuid")) + assertEquals(null, desktopOwnedBackupDestination("Photos/user-backup")) + } + + @Test + fun `completed replacement backup is exposed when its destination also exists`() { + val backup = "Photos/.today.md.nextcloud-native-backup-123e4567-e89b-12d3-a456-426614174000" + + assertEquals(false, shouldSuppressDesktopOwnedBackup(backup, setOf(backup, "Photos/today.md"))) + assertEquals(true, shouldSuppressDesktopOwnedBackup(backup, setOf(backup))) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditionsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditionsTest.kt new file mode 100644 index 000000000..78e609c37 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRuntimeConditionsTest.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopFileSyncRuntimeConditionsTest { + @Test + fun `metered probe fails closed for unknown connected costs`() { + assertTrue( + parseNmcliMeteredProbe("GENERAL.STATE:100 (connected)\nGENERAL.METERED:no (guessed)") == true, + ) + assertFalse( + parseNmcliMeteredProbe("GENERAL.STATE:100 (connected)\nGENERAL.METERED:guess-yes") == true, + ) + assertNull(parseNmcliMeteredProbe("GENERAL.STATE:30 (disconnected)\nGENERAL.METERED:unknown")) + } + + @Test + fun `configured network and power policies gate automatic sync`() { + val configuration = FileSyncConfiguration( + deviceLabel = "desktop", + networkPolicy = FileSyncNetworkPolicy.Unmetered, + powerPolicy = FileSyncPowerPolicy.Charging, + ) + assertTrue(DesktopFileSyncRuntimeConditions(true, true, 40, true).allows(configuration)) + assertFalse(DesktopFileSyncRuntimeConditions(false, true, 40, true).allows(configuration)) + assertFalse(DesktopFileSyncRuntimeConditions(true, true, 40, false).allows(configuration)) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt new file mode 100644 index 000000000..add5718d1 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt @@ -0,0 +1,152 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopFileSyncStoreTest { + @Test + fun `download capacity includes reserve and both same-store copies`() { + assertEquals(250L, requiredDesktopDownloadFreeBytes(100L, 50L, sameStore = true)) + assertEquals(150L, requiredDesktopDownloadFreeBytes(100L, 50L, sameStore = false)) + assertEquals(Long.MAX_VALUE, requiredDesktopDownloadFreeBytes(Long.MAX_VALUE, 1L, sameStore = true)) + } + + @Test + fun `exclusive store transaction serializes independent engine instances`() { + val directory = Files.createTempDirectory("desktop-sync-lock-").toFile() + val executor = Executors.newFixedThreadPool(2) + try { + val stateFile = File(directory, "state.json") + val first = DesktopFileSyncStore(stateFile) + val second = DesktopFileSyncStore(stateFile) + val firstEntered = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val secondEntered = CountDownLatch(1) + val firstFuture = executor.submit { + first.withExclusiveAccess { + firstEntered.countDown() + check(releaseFirst.await(5, TimeUnit.SECONDS)) + } + } + assertTrue(firstEntered.await(5, TimeUnit.SECONDS)) + + val secondFuture = executor.submit { + second.withExclusiveAccess { secondEntered.countDown() } + } + + assertFalse(secondEntered.await(100, TimeUnit.MILLISECONDS)) + releaseFirst.countDown() + firstFuture.get(5, TimeUnit.SECONDS) + secondFuture.get(5, TimeUnit.SECONDS) + assertTrue(secondEntered.await(5, TimeUnit.SECONDS)) + } finally { + executor.shutdownNow() + directory.deleteRecursively() + } + } + + @Test + fun `desktop store preserves advanced policy and recovers running work`() { + val directory = Files.createTempDirectory("desktop-sync-store-").toFile() + try { + val pair = FileSyncPair( + id = "pair", + accountId = "account", + localRootId = "root", + remoteRootPath = "Photos", + configuration = FileSyncConfiguration( + deviceLabel = "Linux workstation", + selectedPaths = listOf("2026/July"), + ignoredPatterns = listOf("*.part"), + priorityRules = listOf( + FileSyncPriorityRule("**/*.raf"), + FileSyncPriorityRule("**/*.jpg"), + ), + ), + ) + var coordinator = scanFileSyncPair( + FileSyncCoordinatorState(listOf(pair)), + "pair", + localEntries = listOf(LocalSyncEntry("2026", SyncEntryKind.Directory, "dir")), + remoteEntries = emptyList(), + nowEpochMillis = 10L, + ) + coordinator = claimNextFileSyncOperation(coordinator, "pair", 20L).state + val expected = DesktopFileSyncPersistedState( + coordinator = coordinator, + roots = listOf(DesktopFileSyncRootRecord("root", directory.absolutePath, "Photos")), + ) + val store = DesktopFileSyncStore(File(directory, "state.json")) + + store.save(expected) + val restored = store.load() + + assertEquals( + FileSyncExecutionState.Ready, + restored.coordinator.pairs.single().workItems.single().state, + ) + assertEquals(pair.configuration, restored.coordinator.pairs.single().configuration) + assertEquals(expected.roots, restored.roots) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `sync mapping overlap detects local and remote ancestry`() { + val root = Files.createTempDirectory("desktop-sync-overlap-") + try { + val child = Files.createDirectories(root.resolve("child")) + val sibling = Files.createDirectories(root.resolveSibling(root.fileName.toString() + "-sibling")) + assertTrue(desktopSyncRootsOverlap(root.toString(), child.toString())) + assertFalse(desktopSyncRootsOverlap(root.toString(), sibling.toString())) + assertTrue(desktopSyncRemoteRootsOverlap("", "Photos")) + assertTrue(desktopSyncRemoteRootsOverlap("Photos", "Photos/RAW")) + assertFalse(desktopSyncRemoteRootsOverlap("Photos", "Documents")) + sibling.toFile().deleteRecursively() + } finally { + root.toFile().deleteRecursively() + } + } + + @Test + fun `local overlap is global while remote overlap is scoped to one account`() { + val root = Files.createTempDirectory("desktop-sync-account-overlap-") + try { + val child = Files.createDirectories(root.resolve("child")) + val sibling = Files.createDirectories(root.resolveSibling(root.fileName.toString() + "-sibling")) + + assertTrue( + desktopSyncMappingsOverlap( + "account-a", "account-b", + root.toString(), child.toString(), + "Photos", "Documents", + ), + ) + assertFalse( + desktopSyncMappingsOverlap( + "account-a", "account-b", + root.toString(), sibling.toString(), + "Photos", "Photos/RAW", + ), + ) + assertTrue( + desktopSyncMappingsOverlap( + "account-a", "account-a", + root.toString(), sibling.toString(), + "Photos", "Photos/RAW", + ), + ) + sibling.toFile().deleteRecursively() + } finally { + root.toFile().deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayStateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayStateTest.kt new file mode 100644 index 000000000..aa61fc518 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncTrayStateTest.kt @@ -0,0 +1,52 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DesktopFileSyncTrayStateTest { + @Test + fun tooltipSummarizesEveryOperationalPhase() { + assertEquals( + "Nextcloud Native - syncing", + DesktopFileSyncTraySnapshot(DesktopFileSyncTrayPhase.Syncing).tooltip(), + ) + assertEquals( + "Nextcloud Native - sync paused", + DesktopFileSyncTraySnapshot(DesktopFileSyncTrayPhase.Paused).tooltip(), + ) + assertEquals( + "Nextcloud Native - attention needed; 1 conflict; 2 failed", + DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.NeedsAttention, + conflictCount = 1, + failedCount = 2, + ).tooltip(), + ) + assertEquals( + "Nextcloud Native - 7 pending", + DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.Idle, + pairCount = 3, + pendingCount = 7, + ).tooltip(), + ) + assertEquals( + "Nextcloud Native - up to date", + DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.Idle, + pairCount = 3, + ).tooltip(), + ) + } + + @Test + fun snapshotRejectsNegativeCountsAndBlankMessages() { + assertFailsWith { + DesktopFileSyncTraySnapshot(DesktopFileSyncTrayPhase.Idle, pendingCount = -1) + } + assertFailsWith { + DesktopFileSyncTraySnapshot(DesktopFileSyncTrayPhase.Idle, message = "") + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStoreTest.kt new file mode 100644 index 000000000..5fa4946ba --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStoreTest.kt @@ -0,0 +1,178 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopLinuxVirtualFileWritebackStoreTest { + @Test + fun `writeback capacity preserves the configured free space reserve`() { + assertTrue(linuxWritebackFitsCapacity(remoteBytes = 40L, availableBytes = 140L, reserveBytes = 100L)) + assertFalse(linuxWritebackFitsCapacity(remoteBytes = 41L, availableBytes = 140L, reserveBytes = 100L)) + assertFalse(linuxWritebackFitsCapacity(remoteBytes = Long.MAX_VALUE, availableBytes = Long.MAX_VALUE, reserveBytes = 1L)) + assertTrue(linuxWritebackGrowthFitsCapacity(currentBytes = 40L, targetBytes = 50L, availableBytes = 110L, reserveBytes = 100L)) + assertFalse(linuxWritebackGrowthFitsCapacity(currentBytes = 40L, targetBytes = 51L, availableBytes = 110L, reserveBytes = 100L)) + } + + @Test + fun `existing file edits are staged and committed with the scanned revision`() { + val directory = Files.createTempDirectory("linux-writeback-").toFile() + try { + val remote = FakeWritebackRemote("before".encodeToByteArray(), "etag-1") + val store = DesktopLinuxVirtualFileWritebackStore(directory, minimumFreeSpaceBytes = { 0L }) + val node = LinuxVirtualFileNode("Notes/today.txt", "today.txt", false, 6L, "etag-1") + val handle = store.open("Notes/today.txt", node, truncate = false, tree = remote) {} + + handle.write(0L, "after!".encodeToByteArray()) + handle.flush() + handle.close() + + assertContentEquals("after!".encodeToByteArray(), remote.content) + assertEquals(listOf("etag-1"), remote.expectedRevisions) + assertEquals(emptyList(), store.pendingWritebacks()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `dirty recovery intent is durable before staged bytes can change`() { + val directory = Files.createTempDirectory("linux-writeback-order-").toFile() + try { + val remote = FakeWritebackRemote("before".encodeToByteArray(), "etag-1") + var interruptMutation = true + val store = DesktopLinuxVirtualFileWritebackStore( + directory, + minimumFreeSpaceBytes = { 0L }, + afterDirtyIntentPersisted = { + if (interruptMutation) { + interruptMutation = false + error("Simulated process interruption before mutation") + } + }, + ) + val node = LinuxVirtualFileNode("Notes/today.txt", "today.txt", false, 6L, "etag-1") + val handle = store.open("Notes/today.txt", node, truncate = false, tree = remote) {} + + assertFails { handle.write(0L, "after!".encodeToByteArray()) } + + assertTrue(store.pendingWritebacks().single().dirty) + val stage = directory.listFiles().orEmpty().single { it.name.endsWith(".stage") } + assertContentEquals("before".encodeToByteArray(), stage.readBytes()) + + assertEquals(6, handle.write(0L, "after!".encodeToByteArray())) + handle.close() + assertContentEquals("after!".encodeToByteArray(), remote.content) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `failed conflict-safe upload remains recoverable after close`() { + val directory = Files.createTempDirectory("linux-writeback-").toFile() + try { + val remote = FakeWritebackRemote("before".encodeToByteArray(), "etag-1").apply { + failWrites = true + } + val store = DesktopLinuxVirtualFileWritebackStore(directory, minimumFreeSpaceBytes = { 0L }) + val node = LinuxVirtualFileNode("Notes/today.txt", "today.txt", false, 6L, "etag-1") + val handle = store.open("Notes/today.txt", node, truncate = false, tree = remote) {} + handle.write(0L, "local!".encodeToByteArray()) + + assertFails { handle.flush() } + assertFails { handle.close() } + assertEquals( + listOf( + DesktopLinuxPendingWriteback( + "Notes/today.txt", + "etag-1", + 6L, + store.pendingWritebacks().single().stagedAtEpochMillis, + dirty = true, + ), + ), + store.pendingWritebacks(), + ) + remote.failWrites = false + assertEquals( + DesktopLinuxWritebackRecoveryResult(recoveredCount = 1, retainedCount = 0), + store.recoverPending(remote) {}, + ) + assertContentEquals("local!".encodeToByteArray(), remote.content) + assertEquals(emptyList(), store.pendingWritebacks()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `recovery accepts exact remote bytes after a lost write response`() { + val directory = Files.createTempDirectory("linux-writeback-lost-response-").toFile() + try { + val remote = FakeWritebackRemote("before".encodeToByteArray(), "etag-1").apply { + failAfterWrite = true + } + val store = DesktopLinuxVirtualFileWritebackStore(directory, minimumFreeSpaceBytes = { 0L }) + val node = LinuxVirtualFileNode("Notes/today.txt", "today.txt", false, 6L, "etag-1") + val handle = store.open("Notes/today.txt", node, truncate = false, tree = remote) {} + handle.write(0L, "saved!".encodeToByteArray()) + + assertFails { handle.close() } + assertEquals(1, store.pendingWritebacks().size) + remote.failAfterWrite = false + + assertEquals( + DesktopLinuxWritebackRecoveryResult(recoveredCount = 1, retainedCount = 0), + store.recoverPending(remote) {}, + ) + assertContentEquals("saved!".encodeToByteArray(), remote.content) + assertEquals(emptyList(), store.pendingWritebacks()) + } finally { + directory.deleteRecursively() + } + } + + private class FakeWritebackRemote( + var content: ByteArray, + var etag: String, + ) : LinuxVirtualWritebackRemote { + val expectedRevisions = mutableListOf() + var failWrites = false + var failAfterWrite = false + + override fun resolveFile(relativePath: String): RemoteSyncEntry? = entry(relativePath) + + override fun stageDownload( + relativePath: String, + expectedRemoteEtag: String, + destination: File, + maximumBytes: Long, + ): RemoteSyncEntry { + require(expectedRemoteEtag == etag) + destination.writeBytes(content) + return entry(relativePath) + } + + override fun writeFile( + relativePath: String, + source: File, + expectedRemoteEtag: String?, + ): RemoteSyncEntry { + expectedRevisions += expectedRemoteEtag + if (failWrites) error("Simulated ETag conflict") + require(expectedRemoteEtag == etag) + content = source.readBytes() + etag = "etag-${expectedRevisions.size + 1}" + if (failAfterWrite) error("Simulated lost write response") + return entry(relativePath) + } + + private fun entry(path: String) = RemoteSyncEntry(path, SyncEntryKind.File, etag, content.size.toLong()) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLoginTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLoginTest.kt new file mode 100644 index 000000000..b06320d63 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopStartOnLoginTest.kt @@ -0,0 +1,68 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopStartOnLoginTest { + @Test + fun linuxAutostartEntryIsOwnedQuotedAndReversible() { + val root = createTempDirectory("nextcloud-native-startup").toFile() + val launcher = File(root, "Nextcloud Native/bin/Nextcloud Native").apply { + parentFile.mkdirs() + writeText("launcher") + } + val controller = DesktopStartOnLoginController( + osName = "Linux", + userHome = root, + linuxConfigHome = File(root, ".config"), + launcherPath = launcher.absolutePath, + ) + + assertTrue(controller.configure(enabled = true).configured) + val entry = File(root, ".config/autostart/nextcloud-native.desktop") + assertTrue(entry.isFile) + assertTrue(entry.readText().contains("Exec=\"${launcher.absolutePath}\"")) + assertFalse(entry.readText().contains("Terminal=true")) + + assertTrue(controller.configure(enabled = false).configured) + assertFalse(entry.exists()) + } + + @Test + fun windowsRegistrationUsesTheCurrentUserRunKey() { + val root = createTempDirectory("nextcloud-native-startup-windows").toFile() + val launcher = File(root, "NextcloudNative.exe").apply { writeText("launcher") } + var command = emptyList() + val result = DesktopStartOnLoginController( + osName = "Windows 11", + userHome = root, + linuxConfigHome = File(root, ".config"), + launcherPath = launcher.absolutePath, + processRunner = { + command = it + 0 + }, + ).configure(enabled = true) + + assertTrue(result.configured) + assertEquals("reg.exe", command.first()) + assertTrue(command.contains("HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run")) + assertTrue(command.contains("\"${launcher.absolutePath}\"")) + } + + @Test + fun developmentLaunchDoesNotWriteStartupState() { + val result = DesktopStartOnLoginController( + osName = "Linux", + userHome = createTempDirectory("nextcloud-native-startup-dev").toFile(), + linuxConfigHome = createTempDirectory("nextcloud-native-startup-dev-config").toFile(), + launcherPath = null, + ).configure(enabled = true) + + assertFalse(result.configured) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt new file mode 100644 index 000000000..024c9c5ae --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt @@ -0,0 +1,106 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class DesktopVirtualRangeCacheTest { + @Test + fun `combined automatic cache budget is shared while pinned Windows bytes are excluded`() { + assertEquals( + 7L, + combinedAutomaticCacheExcess( + maximumBytes = 20L, + completeFileBytes = 9L, + rangeBytes = 8L, + windowsCachedBytes = 15L, + windowsPinnedBytes = 5L, + ), + ) + assertEquals( + 0L, + combinedAutomaticCacheExcess(20L, 5L, 5L, windowsCachedBytes = 15L, windowsPinnedBytes = 5L), + ) + } + + @Test + fun `exact revision blocks survive cache restart`() { + val directory = Files.createTempDirectory("virtual-range-cache-").toFile() + try { + val cache = DesktopVirtualRangeCache(directory) { VirtualFileCachePolicy() } + cache.storeBlock(ACCOUNT_ID, "Photos/example.raf", "etag-1", 8L, 0L, "abcd".encodeToByteArray()) + + val restarted = DesktopVirtualRangeCache(directory) { VirtualFileCachePolicy() } + assertContentEquals( + "abcd".encodeToByteArray(), + restarted.readBlock(ACCOUNT_ID, "Photos/example.raf", "etag-1", 8L, 0L, 4), + ) + assertEquals(null, restarted.readBlock(ACCOUNT_ID, "Photos/example.raf", "etag-2", 8L, 0L, 4)) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `active virtual file blocks are protected until the final handle closes`() { + val directory = Files.createTempDirectory("virtual-range-cache-").toFile() + try { + val cache = DesktopVirtualRangeCache(directory) { + VirtualFileCachePolicy(maximumCacheBytes = 1L, minimumFreeSpaceBytes = 0L, unusedFileAgeMillis = null) + } + cache.acquire(ACCOUNT_ID, "Photos/example.raf") + cache.storeBlock(ACCOUNT_ID, "Photos/example.raf", "etag-1", 4L, 0L, "data".encodeToByteArray()) + assertEquals(4L, cache.summary(ACCOUNT_ID).cachedBytes) + + cache.freeUp(ACCOUNT_ID, 4L) + assertEquals(4L, cache.summary(ACCOUNT_ID).cachedBytes) + + cache.release(ACCOUNT_ID, "Photos/example.raf") + cache.freeUp(ACCOUNT_ID, 4L) + assertEquals(0L, cache.summary(ACCOUNT_ID).cachedBytes) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `oversized range index rejects a block without leaving an orphan blob`() { + val directory = Files.createTempDirectory("virtual-range-cache-index-").toFile() + try { + val cache = DesktopVirtualRangeCache( + root = directory, + policy = { VirtualFileCachePolicy() }, + maximumIndexBytes = 1_024L, + ) + cache.storeBlock(ACCOUNT_ID, "Photos/kept.raf", "etag-1", 4L, 0L, "kept".encodeToByteArray()) + val accountDirectory = directory.resolve(ACCOUNT_ID) + val originalBlocks = accountDirectory.listFiles().orEmpty().filter { it.extension == "block" } + + assertFailsWith { + cache.storeBlock( + ACCOUNT_ID, + "Photos/${"a".repeat(900)}.raf", + "etag-2", + 4L, + 0L, + "next".encodeToByteArray(), + ) + } + + assertEquals(originalBlocks.map { it.name }, accountDirectory.listFiles().orEmpty() + .filter { it.extension == "block" }.map { it.name }) + assertContentEquals( + "kept".encodeToByteArray(), + cache.readBlock(ACCOUNT_ID, "Photos/kept.raf", "etag-1", 4L, 0L, 4), + ) + } finally { + directory.deleteRecursively() + } + } + + private companion object { + const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt new file mode 100644 index 000000000..b0e7537f7 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt @@ -0,0 +1,465 @@ +package dev.obiente.nextcloudnative.app + +import jnr.ffi.Runtime +import jnr.ffi.Pointer +import java.nio.ByteBuffer +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import ru.serce.jnrfuse.ErrorCodes +import ru.serce.jnrfuse.struct.FileStat +import ru.serce.jnrfuse.struct.FuseFileInfo + +class LinuxVirtualFileSystemTest { + @Test + fun `open read seek and release use one generation pinned handle`() { + val bytes = "nextcloud virtual file".encodeToByteArray() + var opened = 0 + var closed = 0 + val backend = fixtureBackend(bytes) { opened += 1; { closed += 1 } } + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + val runtime = Runtime.getSystemRuntime() + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val output = runtime.memoryManager.allocateDirect(8) + + assertEquals(0, fileSystem.open("/Photos/example.raf", fileInfo)) + assertEquals(1, opened) + assertEquals(7, fileSystem.read("/Photos/example.raf", output, 7L, 10L, fileInfo)) + val read = ByteArray(7).also { destination -> output.get(0L, destination, 0, destination.size) } + assertContentEquals(bytes.copyOfRange(10, 17), read) + assertEquals(0, fileSystem.release("/Photos/example.raf", fileInfo)) + assertEquals(1, closed) + } + + @Test + fun `metadata is visible without hydrating file content`() { + var opened = 0 + val backend = fixtureBackend("content".encodeToByteArray()) { opened += 1; {} } + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + val stat = FileStat(Runtime.getSystemRuntime()) + + assertEquals(0, fileSystem.getattr("/Photos/example.raf", stat)) + assertEquals(7L, stat.st_size.longValue()) + assertTrue(FileStat.S_ISREG(stat.st_mode.intValue())) + assertEquals(0, opened) + assertEquals(-ErrorCodes.EINVAL(), fileSystem.getattr("/Photos/../secret", stat)) + } + + @Test + fun `created files stage random writes and become remote on flush`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + val runtime = Runtime.getSystemRuntime() + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val input = runtime.memoryManager.allocateDirect(8) + input.put(0L, "RAF-data".encodeToByteArray(), 0, 8) + + assertEquals(0, fileSystem.create("/Photos/new.raf", 0L, fileInfo)) + assertEquals(8, fileSystem.write("/Photos/new.raf", input, 8L, 0L, fileInfo)) + assertEquals(0, fileSystem.getattr("/Photos/new.raf", FileStat(runtime))) + assertEquals(null, backend.resolve("Photos/new.raf")) + + assertEquals(0, fileSystem.fsync("/Photos/new.raf", 0, fileInfo)) + assertContentEquals("RAF-data".encodeToByteArray(), backend.fileBytes("Photos/new.raf")) + assertEquals(0, fileSystem.release("/Photos/new.raf", fileInfo)) + } + + @Test + fun `pending created file can be reopened with an independent read handle`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + val runtime = Runtime.getSystemRuntime() + val creator = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val reader = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val input = runtime.memoryManager.allocateDirect(5) + input.put(0L, "draft".encodeToByteArray(), 0, 5) + + assertEquals(0, fileSystem.create("/Photos/draft.txt", 0L, creator)) + assertEquals(5, fileSystem.write("/Photos/draft.txt", input, 5L, 0L, creator)) + assertEquals(0, fileSystem.access("/Photos/draft.txt", 0)) + assertEquals(0, fileSystem.open("/Photos/draft.txt", reader)) + val output = runtime.memoryManager.allocateDirect(5) + assertEquals(5, fileSystem.read("/Photos/draft.txt", output, 5L, 0L, reader)) + assertContentEquals( + "draft".encodeToByteArray(), + ByteArray(5).also { bytes -> output.get(0L, bytes, 0, bytes.size) }, + ) + + assertEquals(0, fileSystem.release("/Photos/draft.txt", creator)) + assertEquals(null, backend.resolve("Photos/draft.txt")) + assertEquals(0, fileSystem.release("/Photos/draft.txt", reader)) + assertContentEquals("draft".encodeToByteArray(), backend.fileBytes("Photos/draft.txt")) + } + + @Test + fun `directory and namespace mutations validate parents`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + + assertEquals(-ErrorCodes.ENOENT(), fileSystem.mkdir("/Missing/Child", 0L)) + assertEquals(0, fileSystem.mkdir("/Photos/Trips", 0L)) + assertEquals(0, fileSystem.rename("/Photos/Trips", "/Archive")) + assertTrue(backend.resolve("Archive")?.directory == true) + assertEquals(0, fileSystem.rmdir("/Archive")) + assertEquals(null, backend.resolve("Archive")) + } + + @Test + fun `rename atomically replaces an existing file for editor save workflows`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/.notes.txt.tmp", "new".encodeToByteArray()) + backend.addFile("Photos/notes.txt", "old".encodeToByteArray()) + + assertEquals(0, fileSystem.rename("/Photos/.notes.txt.tmp", "/Photos/notes.txt")) + assertEquals(null, backend.resolve("Photos/.notes.txt.tmp")) + assertContentEquals("new".encodeToByteArray(), backend.fileBytes("Photos/notes.txt")) + } + + @Test + fun `open read handle follows a remote rename until release`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/open.txt", "read after rename".encodeToByteArray()) + val runtime = Runtime.getSystemRuntime() + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + + assertEquals(0, fileSystem.open("/Photos/open.txt", fileInfo)) + assertEquals(0, fileSystem.rename("/Photos/open.txt", "/Photos/renamed.txt")) + val output = runtime.memoryManager.allocateDirect(17) + assertEquals(17, fileSystem.read("/Photos/renamed.txt", output, 17L, 0L, fileInfo)) + assertContentEquals( + "read after rename".encodeToByteArray(), + ByteArray(17).also { bytes -> output.get(0L, bytes, 0, bytes.size) }, + ) + assertEquals(0, fileSystem.release("/Photos/renamed.txt", fileInfo)) + } + + @Test + fun `rename replacement waits for an open destination generation`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/replacement.txt", "new".encodeToByteArray()) + backend.addFile("Photos/open.txt", "old generation".encodeToByteArray()) + val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)) + + assertEquals(0, fileSystem.open("/Photos/open.txt", fileInfo)) + assertEquals( + -ErrorCodes.EBUSY(), + fileSystem.rename("/Photos/replacement.txt", "/Photos/open.txt"), + ) + assertContentEquals("old generation".encodeToByteArray(), backend.fileBytes("Photos/open.txt")) + assertEquals(0, fileSystem.release("/Photos/open.txt", fileInfo)) + assertEquals(0, fileSystem.rename("/Photos/replacement.txt", "/Photos/open.txt")) + assertContentEquals("new".encodeToByteArray(), backend.fileBytes("Photos/open.txt")) + } + + @Test + fun `unlink waits for an open read handle instead of acknowledging a volatile delete`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/open.txt", "read after unlink".encodeToByteArray()) + val runtime = Runtime.getSystemRuntime() + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + + assertEquals(0, fileSystem.open("/Photos/open.txt", fileInfo)) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.unlink("/Photos/open.txt")) + assertEquals(0, fileSystem.getattr("/Photos/open.txt", FileStat(runtime))) + assertTrue(backend.resolve("Photos/open.txt") != null) + val output = runtime.memoryManager.allocateDirect(17) + assertEquals(17, fileSystem.read("/Photos/open.txt", output, 17L, 0L, fileInfo)) + assertContentEquals( + "read after unlink".encodeToByteArray(), + ByteArray(17).also { bytes -> output.get(0L, bytes, 0, bytes.size) }, + ) + + assertEquals(0, fileSystem.release("/Photos/open.txt", fileInfo)) + assertEquals(0, fileSystem.unlink("/Photos/open.txt")) + assertEquals(null, backend.resolve("Photos/open.txt")) + } + + @Test + fun `unlink rejects a file with an open writeback handle`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/open.txt", "pending edit".encodeToByteArray()) + val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)).apply { + flags.set(1L) + } + + assertEquals(0, fileSystem.open("/Photos/open.txt", fileInfo)) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.unlink("/Photos/open.txt")) + assertTrue(backend.resolve("Photos/open.txt") != null) + assertEquals(0, fileSystem.release("/Photos/open.txt", fileInfo)) + assertEquals(0, fileSystem.unlink("/Photos/open.txt")) + assertEquals(null, backend.resolve("Photos/open.txt")) + } + + @Test + fun `parent rename remains available after an open child is safely removed`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.createDirectory("Photos/Working") + backend.addFile("Photos/Working/open.txt", "pending delete".encodeToByteArray()) + val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)) + + assertEquals(0, fileSystem.open("/Photos/Working/open.txt", fileInfo)) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.unlink("/Photos/Working/open.txt")) + assertEquals(0, fileSystem.release("/Photos/Working/open.txt", fileInfo)) + assertEquals(0, fileSystem.unlink("/Photos/Working/open.txt")) + assertEquals(null, backend.resolve("Photos/Working/open.txt")) + assertEquals(0, fileSystem.rename("/Photos/Working", "/Photos/Renamed")) + } + + @Test + fun `rename refuses a directory containing an open write handle`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.createDirectory("Photos/Working") + backend.addFile("Photos/Working/notes.txt", "draft".encodeToByteArray()) + val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)).apply { + flags.set(1L) + } + + assertEquals(0, fileSystem.open("/Photos/Working/notes.txt", fileInfo)) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.rename("/Photos/Working", "/Photos/Renamed")) + assertEquals(0, fileSystem.release("/Photos/Working/notes.txt", fileInfo)) + assertEquals(0, fileSystem.rename("/Photos/Working", "/Photos/Renamed")) + } + + @Test + fun `rmdir refuses a non empty remote directory`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.createDirectory("Photos/Trips") + backend.addFile("Photos/Trips/photo.raf", "raw".encodeToByteArray()) + + assertEquals(-ErrorCodes.ENOTEMPTY(), fileSystem.rmdir("/Photos/Trips")) + assertTrue(backend.resolve("Photos/Trips/photo.raf") != null) + } + + @Test + fun `readdir resumes from the supplied continuation offset`() { + val backend = MutableFixtureBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + backend.addFile("Photos/a.raf", byteArrayOf(1)) + backend.addFile("Photos/b.raf", byteArrayOf(2)) + val runtime = Runtime.getSystemRuntime() + val buffer = runtime.memoryManager.allocateDirect(8) + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val firstPage = mutableListOf>() + val firstFiller = ru.serce.jnrfuse.FuseFillDir { _: Pointer, name: ByteBuffer, _, nextOffset: Long -> + if (firstPage.size == 2) return@FuseFillDir 1 + firstPage += name.fuseName() to nextOffset + 0 + } + + assertEquals(0, fileSystem.readdir("/Photos", buffer, firstFiller, 0L, fileInfo)) + val secondPage = mutableListOf() + val secondFiller = ru.serce.jnrfuse.FuseFillDir { _: Pointer, name: ByteBuffer, _, _ -> + secondPage += name.fuseName() + 0 + } + assertEquals( + 0, + fileSystem.readdir("/Photos", buffer, secondFiller, firstPage.last().second, fileInfo), + ) + assertEquals(listOf(".", ".."), firstPage.map { it.first }) + assertEquals(listOf("a.raf", "b.raf"), secondPage) + } + + @Test + fun `release reports a close time writeback failure`() { + val backend = MutableFixtureBackend(failClose = true) + val fileSystem = LinuxNextcloudVirtualFileSystem(backend) + val runtime = Runtime.getSystemRuntime() + val fileInfo = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + + assertEquals(0, fileSystem.create("/Photos/failed.raf", 0L, fileInfo)) + assertEquals(-ErrorCodes.EIO(), fileSystem.release("/Photos/failed.raf", fileInfo)) + } + + private fun fixtureBackend( + bytes: ByteArray, + onOpen: () -> () -> Unit, + ): LinuxVirtualFileBackend { + val root = LinuxVirtualFileNode("", "Nextcloud", true, 0L, "root") + val photos = LinuxVirtualFileNode("Photos", "Photos", true, 0L, "folder-etag") + val file = LinuxVirtualFileNode( + "Photos/example.raf", + "example.raf", + false, + bytes.size.toLong(), + "file-etag", + ) + return object : LinuxVirtualFileBackend { + override fun resolve(path: String): LinuxVirtualFileNode? = when (path.trim('/')) { + "" -> root + "Photos" -> photos + "Photos/example.raf" -> file + else -> null + } + + override fun list(path: String): List = when (path.trim('/')) { + "" -> listOf(photos) + "Photos" -> listOf(file) + else -> emptyList() + } + + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle { + val close = onOpen() + return object : LinuxVirtualFileReadHandle { + override val size: Long = bytes.size.toLong() + + override fun read(offset: Long, length: Int): ByteArray = + bytes.copyOfRange(offset.toInt(), offset.toInt() + length) + + override fun close() = close() + } + } + + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle = error("Write access is not used by this read fixture.") + + override fun createDirectory(path: String) = error("Not used by this read fixture.") + override fun delete(node: LinuxVirtualFileNode) = error("Not used by this read fixture.") + override fun move(node: LinuxVirtualFileNode, destinationPath: String) = + error("Not used by this read fixture.") + + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) = error("Not used by this read fixture.") + } + } + + private class MutableFixtureBackend( + private val failClose: Boolean = false, + ) : LinuxVirtualFileBackend { + private val nodes = linkedMapOf( + "" to LinuxVirtualFileNode("", "Nextcloud", true, 0L, "root"), + "Photos" to LinuxVirtualFileNode("Photos", "Photos", true, 0L, "photos-etag"), + ) + private val contents = linkedMapOf() + + override fun resolve(path: String): LinuxVirtualFileNode? = nodes[path.trim('/')] + + override fun list(path: String): List { + val parent = path.trim('/') + return nodes.values.filter { node -> + node.path.isNotEmpty() && node.path.substringBeforeLast('/', "") == parent + } + } + + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = + object : LinuxVirtualFileReadHandle { + private var currentPath = node.path + override val size: Long = node.size + + override fun read(offset: Long, length: Int): ByteArray = + requireNotNull(contents[currentPath]).copyOfRange(offset.toInt(), offset.toInt() + length) + + override fun readdress(path: String) { + currentPath = path + } + + override fun close() = Unit + } + + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle { + var stagedBytes = if (truncate) byteArrayOf() else contents[path]?.copyOf() ?: byteArrayOf() + return object : LinuxVirtualFileWriteHandle { + override val size: Long get() = stagedBytes.size.toLong() + + override fun read(offset: Long, length: Int): ByteArray = + stagedBytes.copyOfRange(offset.toInt(), offset.toInt() + length) + + override fun write(offset: Long, bytes: ByteArray): Int { + val required = offset.toInt() + bytes.size + if (required > stagedBytes.size) stagedBytes = stagedBytes.copyOf(required) + bytes.copyInto(stagedBytes, offset.toInt()) + return bytes.size + } + + override fun truncate(size: Long) { + stagedBytes = stagedBytes.copyOf(size.toInt()) + } + + override fun flush() { + contents[path] = stagedBytes.copyOf() + nodes[path] = LinuxVirtualFileNode( + path, + path.substringAfterLast('/'), + false, + stagedBytes.size.toLong(), + "etag-${stagedBytes.size}", + ) + } + + override fun close() { + if (failClose) error("Simulated close-time writeback failure") + flush() + } + } + } + + override fun createDirectory(path: String) { + nodes[path] = LinuxVirtualFileNode(path, path.substringAfterLast('/'), true, 0L, "dir-etag") + } + + override fun delete(node: LinuxVirtualFileNode) { + nodes.remove(node.path) + contents.remove(node.path) + } + + override fun move(node: LinuxVirtualFileNode, destinationPath: String) { + nodes.remove(node.path) + val content = contents.remove(node.path) + nodes[destinationPath] = node.copy( + path = destinationPath, + name = destinationPath.substringAfterLast('/'), + ) + if (content != null) contents[destinationPath] = content + } + + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) { + require(nodes[destinationPath]?.remoteRevision == destination.remoteRevision) + nodes.remove(destinationPath) + contents.remove(destinationPath) + move(node, destinationPath) + } + + fun fileBytes(path: String): ByteArray = requireNotNull(contents[path]) + + fun addFile(path: String, bytes: ByteArray) { + contents[path] = bytes.copyOf() + nodes[path] = LinuxVirtualFileNode( + path = path, + name = path.substringAfterLast('/'), + directory = false, + size = bytes.size.toLong(), + remoteRevision = "etag-${bytes.size}", + ) + } + } +} + +private fun ByteBuffer.fuseName(): String { + val copy = duplicate() + val bytes = ByteArray(copy.remaining()) + copy.get(bytes) + return bytes.takeWhile { it != 0.toByte() }.toByteArray().decodeToString() +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt new file mode 100644 index 000000000..46e53d65b --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt @@ -0,0 +1,548 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Path +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WindowsCloudFilesProviderTest { + @Test + fun `native layouts match 64 bit cfapi structures`() { + assertEquals( + WindowsCloudNativeLayoutSizes( + registration = 72, + policies = 24, + fileSystemMetadata = 48, + placeholder = 88, + callbackInfo = 152, + ), + windowsCloudNativeLayoutSizes(), + ) + } + + @Test + fun `placeholder identities round trip and reject tampering`() { + val identity = fixtureIdentity(size = 9_217L) + val encoded = WindowsCloudFileIdentityCodec.encode(identity) + + assertEquals(identity, WindowsCloudFileIdentityCodec.decode(encoded)) + assertTrue(encoded.size <= 4_096) + + val tampered = encoded.copyOf().also { it[12] = (it[12].toInt() xor 1).toByte() } + assertFailsWith { WindowsCloudFileIdentityCodec.decode(tampered) } + } + + @Test + fun `callback paths must stay in the sync root and match their identity`() { + val root = createTempDirectory("windows-cloud-callback-path-") + val expected = root.resolve("Photos/example.raf") + + requireWindowsCloudCallbackPath(root, expected.toString(), "Photos/example.raf") + assertFailsWith { + requireWindowsCloudCallbackPath(root, root.resolve("Photos/other.raf").toString(), "Photos/example.raf") + } + assertFailsWith { + val outside = requireNotNull(root.parent).resolve("outside.raf") + requireWindowsCloudCallbackPath(root, outside.toString(), "Photos/example.raf") + } + } + + @Test + fun `hydration planning aligns random reads and ends exactly at eof`() { + val ranges = planWindowsCloudHydration( + requiredOffset = 4_321L, + requiredLength = 20_000L, + fileSize = 19_111L, + maximumChunkBytes = 8_192, + ) + + assertEquals(4_096L, ranges.first().offset) + assertEquals(19_111L, ranges.last().offset + ranges.last().length) + assertTrue(ranges.dropLast(1).all { it.length % 4_096 == 0 }) + assertTrue(ranges.all { it.offset % 4_096L == 0L }) + val interior = planWindowsCloudHydration(5_001L, 1L, 30_000L) + assertEquals(8_192L, interior.single().offset + interior.single().length) + } + + @Test + fun `fetch callback transfers exact generation in aligned chunks`() { + val root = createTempDirectory("windows-cloud-provider-") + val bytes = ByteArray(12_345) { index -> (index % 251).toByte() } + val backend = FakeBackend(bytes) + val api = FakeApi(expectedTransfers = 1) + val provider = WindowsCloudFilesProvider(root, backend, api) + val identity = fixtureIdentity(size = bytes.size.toLong()) + val info = callbackInfo(root, identity) + + provider.fetchData(info, requiredOffset = 4_500L, requiredLength = 7_845L) + + assertTrue(api.awaitTransfers()) + assertEquals(listOf(4_096L), api.transfers.map { it.first }) + assertContentEquals(bytes.copyOfRange(4_096, bytes.size), api.transfers.flatMap { it.second.asIterable() }.toByteArray()) + provider.close() + } + + @Test + fun `new ordinary local file uploads before conversion to placeholder`() { + val root = createTempDirectory("windows-cloud-local-") + val local = root.resolve("Notes/new.txt") + local.parent.toFile().mkdirs() + local.writeBytes("offline edit".encodeToByteArray()) + val backend = FakeBackend("remote".encodeToByteArray()) + val api = FakeApi(expectedConversions = 1) + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.localEntryChanged(local) + + assertTrue(api.awaitConversions()) + assertEquals("Notes/new.txt", backend.lastUploadedPath) + assertEquals(WindowsCloudPlaceholderState.InSync, api.placeholderState(local)) + provider.close() + } + + @Test + fun `new populated local directory uploads every descendant parent first`() { + val root = createTempDirectory("windows-cloud-local-tree-") + val directory = root.resolve("Projects") + val nested = directory.resolve("Launch") + nested.toFile().mkdirs() + nested.resolve("brief.txt").writeBytes("ready".encodeToByteArray()) + val backend = FakeBackend("remote".encodeToByteArray()) + val api = FakeApi(expectedConversions = 3) + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.localEntryChanged(directory) + + assertTrue(api.awaitConversions()) + assertEquals( + listOf("mkdir:Projects", "mkdir:Projects/Launch", "upload:Projects/Launch/brief.txt"), + backend.operations, + ) + assertEquals(WindowsCloudPlaceholderState.InSync, api.placeholderState(nested.resolve("brief.txt"))) + provider.close() + } + + @Test + fun `ambiguous local create reconciles exact remote bytes before placeholder conversion`() { + val root = createTempDirectory("windows-cloud-ambiguous-create-") + val local = root.resolve("Notes/recovered.txt") + local.parent.toFile().mkdirs() + local.writeBytes("saved once".encodeToByteArray()) + val backend = FakeBackend( + source = "remote".encodeToByteArray(), + expectedUploads = 1, + failAfterUpload = true, + ) + val api = FakeApi(expectedConversions = 1) + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.localEntryChanged(local) + + assertTrue(backend.awaitUploads()) + assertTrue(api.awaitConversions()) + assertEquals(WindowsCloudPlaceholderState.InSync, api.placeholderState(local)) + assertEquals("Notes/recovered.txt", backend.resolve("Notes/recovered.txt")?.path) + provider.close() + } + + @Test + fun `startup invalidates hydrated bytes when the remote generation changed`() { + val root = createTempDirectory("windows-cloud-refresh-") + val local = root.resolve("example.raf") + local.writeBytes("old bytes".encodeToByteArray()) + val old = fixtureIdentity(size = local.toFile().length()).copy(path = "example.raf") + val fresh = old.copy(remoteRevision = "\"etag-02\"") + val backend = FakeBackend("fresh".encodeToByteArray(), listed = listOf(fresh)) + val api = FakeApi().apply { seed(local, WindowsCloudPlaceholderState.InSync, old) } + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.start() + + assertEquals(listOf(local), api.invalidatedUpdates) + provider.close() + } + + @Test + fun `recovery uploads against the dirty placeholder revision`() { + val root = createTempDirectory("windows-cloud-recovery-") + val local = root.resolve("edit.txt") + local.writeBytes("local edit".encodeToByteArray()) + val old = WindowsCloudFileIdentity("account-01", "edit.txt", "\"etag-01\"", local.toFile().length(), false) + val backend = FakeBackend("fresh".encodeToByteArray(), expectedUploads = 1) + val api = FakeApi().apply { seed(local, WindowsCloudPlaceholderState.Dirty, old) } + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.start() + + assertTrue(backend.awaitUploads()) + assertEquals("\"etag-01\"", backend.lastExpectedRemoteRevision) + provider.close() + } + + @Test + fun `local placeholder inventory includes hydrated files absent from bounded remote traversal`() { + val root = createTempDirectory("windows-cloud-local-inventory-") + val local = root.resolve("Archive/cached.raf") + local.parent.toFile().mkdirs() + local.writeBytes("hydrated bytes".encodeToByteArray()) + val identity = WindowsCloudFileIdentity( + "account-01", + "Archive/cached.raf", + "\"etag-01\"", + local.toFile().length(), + false, + ) + val backend = FakeBackend("remote".encodeToByteArray()) + val api = FakeApi(expectedIdentityReads = 1).apply { + seed(local, WindowsCloudPlaceholderState.InSync, identity) + } + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.start() + + assertTrue(api.awaitIdentityReads()) + val inventoryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (provider.summary().cachedBytes != local.toFile().length() && System.nanoTime() < inventoryDeadline) { + Thread.yield() + } + assertEquals(local.toFile().length(), provider.summary().cachedBytes) + assertEquals(1, provider.summary().hydratedFileCount) + provider.close() + } + + @Test + fun `folder rename rebinds every clean descendant identity`() { + val root = createTempDirectory("windows-cloud-rename-") + val destination = root.resolve("Projects/New") + destination.toFile().mkdirs() + val child = destination.resolve("brief.txt") + child.writeBytes("local edit".encodeToByteArray()) + val directoryIdentity = WindowsCloudFileIdentity("account-01", "Projects/Old", "\"dir-v1\"", 0L, true) + val childIdentity = WindowsCloudFileIdentity( + "account-01", + "Projects/Old/brief.txt", + "\"file-v1\"", + child.toFile().length(), + false, + ) + val backend = FakeBackend("remote".encodeToByteArray()) + val api = FakeApi(expectedRenames = 1).apply { + seed(destination, WindowsCloudPlaceholderState.InSync, directoryIdentity) + seed(child, WindowsCloudPlaceholderState.InSync, childIdentity) + } + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.renameRequested(callbackInfo(root, directoryIdentity), destination.toString()) + + assertTrue(api.awaitRenames()) + assertTrue(api.lastRenameAccepted) + assertEquals("Projects/New/brief.txt", api.decodedIdentity(child)?.path) + assertEquals(WindowsCloudPlaceholderState.InSync, api.placeholderState(child)) + provider.close() + } + + @Test + fun `rename rejects a dirty placeholder until writeback completes`() { + val root = createTempDirectory("windows-cloud-dirty-rename-") + val destination = root.resolve("renamed.txt") + destination.writeBytes("local edit".encodeToByteArray()) + val identity = WindowsCloudFileIdentity( + "account-01", + "original.txt", + "\"file-v1\"", + destination.toFile().length(), + false, + ) + val backend = FakeBackend("remote".encodeToByteArray()) + val api = FakeApi(expectedRenames = 1).apply { + seed(destination, WindowsCloudPlaceholderState.Dirty, identity) + } + val provider = WindowsCloudFilesProvider(root, backend, api) + + provider.renameRequested(callbackInfo(root, identity), destination.toString()) + + assertTrue(api.awaitRenames()) + assertFalse(api.lastRenameAccepted) + assertEquals("original.txt", api.decodedIdentity(destination)?.path) + assertEquals(WindowsCloudPlaceholderState.Dirty, api.placeholderState(destination)) + provider.close() + } + + @Test + fun `a newer close event is coalesced and uploads after the active writeback`() { + val root = createTempDirectory("windows-cloud-coalesce-") + val local = root.resolve("edit.txt") + local.writeBytes("first edit".encodeToByteArray()) + val identity = WindowsCloudFileIdentity( + "account-01", + "edit.txt", + "\"etag-01\"", + local.toFile().length(), + false, + ) + val backend = FakeBackend( + "remote".encodeToByteArray(), + expectedUploads = 2, + blockFirstUpload = true, + ) + val api = FakeApi().apply { seed(local, WindowsCloudPlaceholderState.Dirty, identity) } + val provider = WindowsCloudFilesProvider(root, backend, api) + val info = callbackInfo(root, identity).copy( + normalizedPath = local.toString(), + fileSize = local.toFile().length(), + ) + + provider.closed(info, deleted = false) + assertTrue(backend.awaitFirstUploadStarted()) + local.writeBytes("later edit".encodeToByteArray()) + provider.closed(info.copy(fileSize = local.toFile().length()), deleted = false) + backend.releaseFirstUpload() + + assertTrue(backend.awaitUploads()) + assertEquals( + listOf("first edit", "later edit"), + backend.uploadedBytes.map { it.decodeToString() }, + ) + assertEquals(listOf("\"etag-01\"", "\"uploaded-1\""), backend.uploadExpectedRevisions) + provider.close() + } + + @Test + fun `failed dirty writeback remains visible after bounded retries and a later close can recover it`() { + val root = createTempDirectory("windows-cloud-writeback-retry-") + val local = root.resolve("edit.txt") + local.writeBytes("retained edit".encodeToByteArray()) + val identity = WindowsCloudFileIdentity( + "account-01", + "edit.txt", + "\"etag-01\"", + local.toFile().length(), + false, + ) + val backend = FakeBackend( + source = "remote".encodeToByteArray(), + uploadFailuresRemaining = Int.MAX_VALUE, + ) + val api = FakeApi(expectedConversions = 1).apply { + seed(local, WindowsCloudPlaceholderState.Dirty, identity) + } + val provider = WindowsCloudFilesProvider( + root, + backend, + api, + writebackRetryDelayMillis = { 0L }, + ) + val info = callbackInfo(root, identity).copy(normalizedPath = local.toString()) + + provider.closed(info, deleted = false) + + val failureDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (provider.summary().failedWritebackCount == 0 && System.nanoTime() < failureDeadline) { + Thread.yield() + } + assertEquals(1, provider.summary().pendingWritebackCount) + assertEquals(1, provider.summary().failedWritebackCount) + + backend.uploadFailuresRemaining = 0 + provider.closed(info, deleted = false) + + assertTrue(api.awaitConversions()) + val recoveryDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (provider.summary().pendingWritebackCount != 0 && System.nanoTime() < recoveryDeadline) { + Thread.yield() + } + assertEquals(0, provider.summary().pendingWritebackCount) + assertEquals(0, provider.summary().failedWritebackCount) + provider.close() + } + + private fun fixtureIdentity(size: Long) = WindowsCloudFileIdentity( + accountId = "account-01", + path = "Photos/example.raf", + remoteRevision = "\"etag-01\"", + size = size, + directory = false, + ) + + private fun callbackInfo(root: Path, identity: WindowsCloudFileIdentity) = WindowsCloudCallbackInfo( + connectionKey = 10L, + transferKey = 20L, + requestKey = 30L, + normalizedPath = root.resolve(identity.path.replace('/', File.separatorChar)).toString(), + fileIdentity = WindowsCloudFileIdentityCodec.encode(identity), + fileSize = identity.size, + priorityHint = 12, + ) + + private class FakeBackend( + private val source: ByteArray, + private val listed: List = emptyList(), + expectedUploads: Int = 0, + private val blockFirstUpload: Boolean = false, + private val failAfterUpload: Boolean = false, + @Volatile var uploadFailuresRemaining: Int = 0, + ) : WindowsCloudFilesBackend { + override val accountId: String = "account-01" + private val uploadLatch = CountDownLatch(expectedUploads) + private val firstUploadStarted = CountDownLatch(if (blockFirstUpload) 1 else 0) + private val firstUploadRelease = CountDownLatch(if (blockFirstUpload) 1 else 0) + var lastUploadedPath: String? = null + var lastExpectedRemoteRevision: String? = null + val uploadedBytes = mutableListOf() + val uploadExpectedRevisions = mutableListOf() + val operations = mutableListOf() + private val remoteIdentities = mutableMapOf() + private val remoteContents = mutableMapOf() + + override fun resolve(path: String): WindowsCloudFileIdentity? = synchronized(this) { + remoteIdentities[path] + } + override fun list(path: String): List = + listed.filter { it.path.substringBeforeLast('/', "") == path } + + override fun open(identity: WindowsCloudFileIdentity): WindowsCloudFileReadHandle { + val bytes = synchronized(this) { remoteContents[identity.path]?.copyOf() } ?: source + return object : WindowsCloudFileReadHandle { + override val size: Long = bytes.size.toLong() + override fun read(offset: Long, length: Int): ByteArray = + bytes.copyOfRange(offset.toInt(), offset.toInt() + length) + override fun close() = Unit + } + } + + override fun upload( + path: String, + localFile: File, + expectedRemoteRevision: String?, + ): WindowsCloudFileIdentity { + synchronized(this) { + if (uploadFailuresRemaining > 0) { + uploadFailuresRemaining -= 1 + error("Simulated transient upload failure") + } + } + lastUploadedPath = path + lastExpectedRemoteRevision = expectedRemoteRevision + val bytes = localFile.readBytes() + val uploadNumber = synchronized(uploadedBytes) { + uploadedBytes += bytes + uploadExpectedRevisions += expectedRemoteRevision + uploadedBytes.size + } + val uploaded = WindowsCloudFileIdentity(accountId, path, "\"uploaded-$uploadNumber\"", bytes.size.toLong(), false) + synchronized(this) { + operations += "upload:$path" + remoteIdentities[path] = uploaded + remoteContents[path] = bytes.copyOf() + } + if (blockFirstUpload && uploadNumber == 1) { + firstUploadStarted.countDown() + check(firstUploadRelease.await(5, TimeUnit.SECONDS)) + } + uploadLatch.countDown() + if (failAfterUpload && uploadNumber == 1) error("Simulated lost create response") + return uploaded + } + + override fun createDirectory(path: String): WindowsCloudFileIdentity = synchronized(this) { + WindowsCloudFileIdentity(accountId, path, "\"directory\"", 0L, true).also { created -> + operations += "mkdir:$path" + remoteIdentities[path] = created + } + } + + override fun delete(identity: WindowsCloudFileIdentity) = Unit + override fun move(identity: WindowsCloudFileIdentity, destinationPath: String): WindowsCloudFileIdentity = + identity.copy(path = destinationPath) + + fun awaitUploads(): Boolean = uploadLatch.await(5, TimeUnit.SECONDS) + fun awaitFirstUploadStarted(): Boolean = firstUploadStarted.await(5, TimeUnit.SECONDS) + fun releaseFirstUpload() = firstUploadRelease.countDown() + } + + private class FakeApi( + expectedTransfers: Int = 0, + expectedConversions: Int = 0, + expectedRenames: Int = 0, + expectedIdentityReads: Int = 0, + ) : WindowsCloudFilesApi { + private val transferLatch = CountDownLatch(expectedTransfers) + private val conversionLatch = CountDownLatch(expectedConversions) + private val renameLatch = CountDownLatch(expectedRenames) + private val identityReadLatch = CountDownLatch(expectedIdentityReads) + private val states = HashMap() + private val identities = HashMap() + val transfers = mutableListOf>() + val invalidatedUpdates = mutableListOf() + var lastRenameAccepted = false + + override fun registerSyncRoot(root: Path, syncRootIdentity: ByteArray) = Unit + override fun connect(root: Path, callbacks: WindowsCloudFilesCallbacks): Long = 1L + override fun disconnect(connectionKey: Long) = Unit + override fun createPlaceholders(baseDirectory: Path, placeholders: List) = Unit + override fun transferData(info: WindowsCloudCallbackInfo, offset: Long, bytes: ByteArray) { + synchronized(transfers) { transfers += offset to bytes.copyOf() } + transferLatch.countDown() + } + override fun failData(info: WindowsCloudCallbackInfo, offset: Long, length: Long, message: String) = Unit + override fun completePlaceholderFetch(info: WindowsCloudCallbackInfo, placeholders: List) = Unit + override fun failPlaceholderFetch(info: WindowsCloudCallbackInfo) = Unit + override fun acknowledgeDelete(info: WindowsCloudCallbackInfo, accepted: Boolean) = Unit + override fun acknowledgeRename(info: WindowsCloudCallbackInfo, accepted: Boolean) { + lastRenameAccepted = accepted + renameLatch.countDown() + } + override fun placeholderState(path: Path): WindowsCloudPlaceholderState = + states[path] ?: WindowsCloudPlaceholderState.Absent + override fun allocatedBytes(path: Path): Long = if (states[path] == WindowsCloudPlaceholderState.InSync) { + path.toFile().length() + } else { + 0L + } + override fun lastAccessedAtEpochMillis(path: Path): Long = 1L + override fun isPinned(path: Path): Boolean = false + override fun placeholderIdentity(path: Path): ByteArray? = identities[path]?.copyOf().also { + identityReadLatch.countDown() + } + override fun updatePlaceholder( + path: Path, + placeholder: WindowsCloudPlaceholder, + invalidateContent: Boolean, + preserveSyncState: Boolean, + ) { + if (!preserveSyncState) states[path] = WindowsCloudPlaceholderState.InSync + identities[path] = placeholder.identity.copyOf() + if (invalidateContent) invalidatedUpdates.add(path) + } + override fun convertToPlaceholder(path: Path, placeholder: WindowsCloudPlaceholder) { + states[path] = WindowsCloudPlaceholderState.Dirty + } + override fun markInSync(path: Path) { + states[path] = WindowsCloudPlaceholderState.InSync + conversionLatch.countDown() + } + override fun dehydrate(path: Path): Long = 0L + override fun close() = Unit + + fun awaitTransfers(): Boolean = transferLatch.await(5, TimeUnit.SECONDS) + fun awaitConversions(): Boolean = conversionLatch.await(5, TimeUnit.SECONDS) + fun awaitRenames(): Boolean = renameLatch.await(5, TimeUnit.SECONDS) + fun awaitIdentityReads(): Boolean = identityReadLatch.await(5, TimeUnit.SECONDS) + + fun decodedIdentity(path: Path): WindowsCloudFileIdentity? = + placeholderIdentity(path)?.let(WindowsCloudFileIdentityCodec::decode) + + fun seed(path: Path, state: WindowsCloudPlaceholderState, identity: WindowsCloudFileIdentity) { + states[path] = state + identities[path] = WindowsCloudFileIdentityCodec.encode(identity) + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnershipTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnershipTest.kt index 454cc51e9..0cc8685b9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnershipTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnershipTest.kt @@ -73,6 +73,7 @@ class MarketingCaptureOwnershipTest { expectedCaptureSources = listOf("source.kt"), expectedCaptureSourceSha256 = "1".repeat(64), expectedAvatarSha256 = "2".repeat(64), + preservedFileNames = emptySet(), ) writePng(staged.resolve(entry.fileName), entry.width + 1, entry.height) @@ -84,6 +85,7 @@ class MarketingCaptureOwnershipTest { listOf("source.kt"), "1".repeat(64), "2".repeat(64), + emptySet(), ) } @@ -96,6 +98,7 @@ class MarketingCaptureOwnershipTest { listOf("source.kt"), "1".repeat(64), "2".repeat(64), + emptySet(), ) } } @@ -187,6 +190,7 @@ class MarketingCaptureOwnershipTest { listOf("source.kt"), "1".repeat(64), "2".repeat(64), + emptySet(), ) } diff --git a/website/public/screenshots/adaptive-dynamic-collection-mobile.png b/website/public/screenshots/adaptive-dynamic-collection-mobile.png index 1e92e0419..d207be685 100644 Binary files a/website/public/screenshots/adaptive-dynamic-collection-mobile.png and b/website/public/screenshots/adaptive-dynamic-collection-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png b/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png index e01ac11e8..1d5b724dd 100644 Binary files a/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png and b/website/public/screenshots/adaptive-dynamic-context-menu-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-data-mobile.png b/website/public/screenshots/adaptive-dynamic-data-mobile.png index 05f22ca3d..6ec8f2b39 100644 Binary files a/website/public/screenshots/adaptive-dynamic-data-mobile.png and b/website/public/screenshots/adaptive-dynamic-data-mobile.png differ diff --git a/website/public/screenshots/adaptive-dynamic-data.png b/website/public/screenshots/adaptive-dynamic-data.png index d00b5c64b..c213b3a67 100644 Binary files a/website/public/screenshots/adaptive-dynamic-data.png and b/website/public/screenshots/adaptive-dynamic-data.png differ diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index bb8b48b92..2122264aa 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -56,6 +56,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCenter.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinatorSnapshot.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncPlanning.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt", @@ -151,6 +152,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessages.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/UnifiedSearch.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/UnifiedSearchScreen.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileCache.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFileStorageCenter.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellLayout.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShellShortcuts.kt", @@ -197,6 +200,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/template/BracedTemplate.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DeckInteractionPreviewMain.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/DynamicBoardInteractionPreviewMain.kt", + "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/FileSyncTrayVisualQaMain.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/Main.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureMain.kt", "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt", @@ -209,7 +213,7 @@ "ui/src/desktopMain/resources/marketing/raw-render-fixture.png", "ui/src/desktopMain/resources/marketing/raw-render-fixture.svg" ], - "captureSourceSha256": "706ace5457f584c09975059b1a22fc4b6095729f79cefe96dc57a77ff3b37f9b", + "captureSourceSha256": "b99c4fb59a31041a2735ed7b02f415c6bffc92a6283721904483ae8cc9090635", "avatarSha256": "a20433eeda834a418f92d76853633b4fc9115ad3006c5622ce2611432dc1f14d", "captures": [ { @@ -224,7 +228,7 @@ "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "1495005bbb47a669a52ebd4931e821e1cbc7a76608b7df608382aabf7fa41f95" + "sha256": "8c51b7a628149cd9c7dad580de118d55e653eb11708651d699346393e0cdb814" }, { "scenario": "mobile-home", @@ -238,7 +242,7 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "f92c861a4800b7a52886eb902849c5719ebc6db6eadfa6ea4e8d4ae8600ce714" + "sha256": "071f9d85242b37a8cac81a06fb23e290d452bd0c48923c910155fc50882336bc" }, { "scenario": "obsidian-vault-sync", @@ -252,7 +256,7 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-compact", - "sha256": "d55a72b9dea2139eb28bf2f361726f91a3726df9f9b7aa6c7742c903f2955e57" + "sha256": "92effc92d25f6d8f2a733391e02e87935e670de5ab766b100253c92e68c6ee57" }, { "scenario": "media-backup-queue", @@ -266,7 +270,133 @@ "purpose": "showcase", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "cf3059d8088e16a1171ebdbfc46816c817775ec8173995bf0501597c5abd64fa" + "sha256": "480d55c3a41249a145552c68a651681974be04f662b748c085e695eb1d8f15b2" + }, + { + "scenario": "file-sync-rules-mobile", + "file": "file-sync-rules-mobile.png", + "width": 1080, + "height": 2200, + "density": 2.625, + "feature": "File sync", + "surface": "Folder pair configuration", + "state": "Selective, ignore, and priority rules", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "cdaeebb040a176f9355e05fc78cbd7ec094a9cb3e33acfdd37a50b9b454f54df" + }, + { + "scenario": "file-sync-status-mobile", + "file": "file-sync-status-mobile.png", + "width": 1080, + "height": 2200, + "density": 2.625, + "feature": "File sync", + "surface": "Folder sync center", + "state": "Conflict recovery and mapping health", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "416941a8930fd0c3912ec03a86647769712607ab80e0de1304cde395f2e772af" + }, + { + "scenario": "file-sync-status-desktop", + "file": "file-sync-status-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "File sync", + "surface": "Folder sync center", + "state": "Priority queue, conflict, and failure", + "purpose": "state-coverage", + "platform": "linux", + "viewport": "wide", + "sha256": "f9abfbd6811164c1d4555c86f272c563ca00f848488d27039971fd3c2579ba80" + }, + { + "scenario": "file-sync-setup-desktop", + "file": "file-sync-setup-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "File sync", + "surface": "Folder pair configuration", + "state": "Guided RAW-first setup", + "purpose": "state-coverage", + "platform": "linux", + "viewport": "wide", + "sha256": "e87550eb59e37ea3272d2111efd063fcfa21ba953856d41e3ce48043e45fb931" + }, + { + "scenario": "file-sync-selection-desktop", + "file": "file-sync-selection-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "File sync", + "surface": "Selective sync browser", + "state": "Verified folders and files", + "purpose": "state-coverage", + "platform": "linux", + "viewport": "wide", + "sha256": "1ab3ccc92d7e04d84fbae123e4f2ecb216a6e6aa507e0df1343738906ab46c05" + }, + { + "scenario": "file-sync-selection-mobile", + "file": "file-sync-selection-mobile.png", + "width": 1080, + "height": 2200, + "density": 2.625, + "feature": "File sync", + "surface": "Selective sync browser", + "state": "Verified folders and files", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "5dfa7a84ec3fd2aa61ef63721dc2e2d01747df5e6f7ac029bfa93101bce9705b" + }, + { + "scenario": "virtual-file-storage-mobile", + "file": "virtual-file-storage-mobile.png", + "width": 1080, + "height": 2200, + "density": 2.625, + "feature": "Virtual files", + "surface": "Storage rules", + "state": "Automatic cleanup with protected pins", + "purpose": "state-coverage", + "platform": "mobile", + "viewport": "phone-portrait", + "sha256": "16323447e8142814960387f19e88b5181c8137baf0df41896278bbb28a4d3164" + }, + { + "scenario": "virtual-file-storage-desktop", + "file": "virtual-file-storage-desktop.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "Virtual files", + "surface": "Storage overview", + "state": "Hydrated cache, pins, and free-up action", + "purpose": "state-coverage", + "platform": "linux", + "viewport": "wide", + "sha256": "c84fc9b38f87706be8604554b5f38744f33482ac00c08c5007cf69d7ab6e13b4" + }, + { + "scenario": "desktop-startup-settings", + "file": "desktop-startup-settings.png", + "width": 1440, + "height": 900, + "density": 1.0, + "feature": "File sync", + "surface": "Desktop settings", + "state": "Start on login enabled", + "purpose": "state-coverage", + "platform": "desktop", + "viewport": "wide", + "sha256": "7d09486e46635bbef8686eeb60a0f4d12755c55fd3d6839e382719fd168f0165" }, { "scenario": "adaptive-dynamic-data", @@ -280,7 +410,7 @@ "purpose": "showcase", "platform": "desktop", "viewport": "wide", - "sha256": "ddbe82d0dc42a3902f41897231147402d7e3b6d0c0b51a15ce69ff1d2cf40202" + "sha256": "839e03dca564c5337ad6c1b941e692690a2fcac2c415ecac055265467fa54b45" }, { "scenario": "adaptive-dynamic-data-mobile", @@ -294,7 +424,7 @@ "purpose": "state-coverage", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "304a7dea161c51ec61978764db46d69fbf9194476fe7ae05b8c900b645fe59c7" + "sha256": "58362248560249ad0978ae981764d80beb6a38ed352807addce472ce70ce85ff" }, { "scenario": "adaptive-dynamic-collection-mobile", @@ -308,7 +438,7 @@ "purpose": "state-coverage", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "849dbda0b56d984944f77b7d547d03b9bf3aaf0ac6de2d96ce6138445d65f796" + "sha256": "a9007162fc1d2bc7a47817d2d66a2e77103573fb9cdc79537233d5b81e36728a" }, { "scenario": "adaptive-dynamic-context-menu-mobile", @@ -322,7 +452,7 @@ "purpose": "state-coverage", "platform": "mobile", "viewport": "phone-portrait", - "sha256": "e483aa2640fc4df5c905c1a79af01c522cc2fa8c7ffc511433bd730329551080" + "sha256": "c38a69e6e2bdc734973c8424e4a3f4ec726d14f2a4f8b522393248e349351f7e" }, { "scenario": "photo-timeline-revalidation-error-mobile", @@ -338,7 +468,7 @@ "viewport": "phone-portrait", "pullRequest": 246, "issue": 242, - "sha256": "74b26981a5d7b8abfe3837acc238d69e99a34db6f2af88cda7d20b41d5b32d03" + "sha256": "60d76a9530c215f5d5473975e162dda5b192fc56e3672401f53e9c0774c10740" }, { "scenario": "photo-timeline-return-to-newest-error-mobile", @@ -354,7 +484,7 @@ "viewport": "phone-portrait", "pullRequest": 246, "issue": 242, - "sha256": "dbe920a60aed29947f5c81c30d173c67e0013506a84329ebefb57125fe702b2f" + "sha256": "c6960155703b6bee4216b684adda94c9c4b5325e621d10d7c24be2cd0be5213f" }, { "scenario": "photo-timeline-raw-retry-mobile", @@ -370,7 +500,7 @@ "viewport": "phone-portrait", "pullRequest": 249, "issue": 248, - "sha256": "6af1b7d716ea58413727e3e5ce1fbd178d772b583e8481ebc71a5cd6fa1ebccd" + "sha256": "b82e70232c249715c0e18db678f24bc84f1c77db2463770a8a2c5b4d45c62ee9" }, { "scenario": "photo-folder-browser-mobile", @@ -386,7 +516,7 @@ "viewport": "phone-portrait", "pullRequest": 245, "issue": 243, - "sha256": "60c32a8efa89d4f1c6d006a44912abd6504cdf7321002b0e886f9ea45204d219" + "sha256": "c24e27abd1a897d556c0dac47448bf00da2deb1ab12065401aae05f730de6a35" }, { "scenario": "photo-folder-browser-desktop", @@ -402,7 +532,7 @@ "viewport": "wide", "pullRequest": 245, "issue": 243, - "sha256": "c22be231ab73c80eeb8619932b7830463100fe0aa1293ee0f31660f3b09cabfd" + "sha256": "0222cf8131beeff16e5ab0448b8de4e3cf7616520a5781bac015d2755335d5b5" }, { "scenario": "raw-preview-loading-mobile", @@ -418,7 +548,7 @@ "viewport": "phone-compact", "pullRequest": 218, "issue": 85, - "sha256": "14744822f7b9e60015675f2515f3b7e9685345ef3d56c080556f2cf9acc935e7" + "sha256": "05e2c33c9a37cad7a95808419b77e72b7e33c1d4274e658cb04e65edac9c0193" }, { "scenario": "raw-preview-error-mobile", @@ -434,7 +564,7 @@ "viewport": "phone-compact", "pullRequest": 218, "issue": 85, - "sha256": "7aef0508cdbae8d691c9187847de3c0d188cef65465021ce861aee6d3eb0863a" + "sha256": "01d0d42f3dc57b3f0a5da0353ad40f30a9e9e219fbc52b0c65358d72c41758d0" }, { "scenario": "raw-preview-memories-ready-mobile", @@ -450,7 +580,7 @@ "viewport": "phone-portrait", "pullRequest": 218, "issue": 85, - "sha256": "0381f21fa25ef09aeca8d7a9606f39eab96a8ad0e5a2178ef1fc14561fa0fc39" + "sha256": "25cd22beb5640c73269b5b786fc6a6525e0913f402c17c29f963c2709c8b2a7a" }, { "scenario": "raw-preview-high-detail-desktop", @@ -466,7 +596,7 @@ "viewport": "wide", "pullRequest": 218, "issue": 85, - "sha256": "265c35d941a8ebce4c0408544dcb78cfc0ac3700dd4b1366e04cf2792b451028" + "sha256": "5b0d81b2626247baf3572be949143fee9cdf8392156a5a5eee5d9d70ad160a9e" }, { "scenario": "live-photo-motion-failure-mobile", @@ -482,7 +612,7 @@ "viewport": "phone-compact", "pullRequest": 249, "issue": 182, - "sha256": "2f248b1b9251f0211774cd57c33a9e639c31ca7935147d0a309120f070ae914a" + "sha256": "3fc5ec6e2db43ebbebaca5b764e81d46685543bedfbeb391065d95ef0fc97f52" }, { "scenario": "native-tiff-preview-mobile", @@ -498,7 +628,7 @@ "viewport": "phone-compact", "pullRequest": 249, "issue": 84, - "sha256": "780fbac0bcf219772df7d99b2726deeec27ed9fa2355a16e1576e17ccdbe8ae2" + "sha256": "42a2fba65982fb18c7462b8a2838ad79507f43ebd5443b2cfcbd2bf147fca30d" }, { "scenario": "file-share-user-mobile", @@ -514,7 +644,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "c1084b73d58e6ad6f0dffc2e7e31f6c2388b1fe268205c3f20ff303f14f69aa0" + "sha256": "ea41901e75f47ff662d9b4d2f0ffb5fdf9822f8fde4e0decbdddef7675da8ae0" }, { "scenario": "file-share-group-desktop", @@ -530,7 +660,7 @@ "viewport": "wide", "pullRequest": 219, "issue": 124, - "sha256": "7ec0df01a0a04ff054afb8a50672e64b3edfcf45f35cc15d90f8d396e3ff78ee" + "sha256": "5fc62f679169035bfdb67a2dbe8cea7c8c21a418448896294fc7988814f8e5b6" }, { "scenario": "file-share-loading-mobile", @@ -546,7 +676,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "03646d3ee9b1a809531b586df0014095f0cb1a6b9c885ab5b9d562492e2328e3" + "sha256": "e524289888582bbf6531c9e6467664d187e99c9d23508f020f0aaff638b48c02" }, { "scenario": "file-share-error-mobile", @@ -562,7 +692,7 @@ "viewport": "phone-portrait", "pullRequest": 219, "issue": 124, - "sha256": "5500df269e219eecbf7d9173b11698fef809e2d1b99a39ece1b4c4ec5a112514" + "sha256": "30107de4377bddd5a13fd851bc5283f784ef3fe96f67e8cc81be5e41c35ae83e" }, { "scenario": "transfer-mobile-pending", @@ -578,7 +708,7 @@ "viewport": "phone-portrait", "pullRequest": 220, "issue": 168, - "sha256": "9cc97dd52807da7d43fa0ae5a2ec1b2d1acf8258d5289a38262ea7c8ffd8b992" + "sha256": "5a8ec403de582eab401e9c2173df8e5fdddabf7bf5664dc5a592eb037f9109e2" }, { "scenario": "transfer-mobile-failed-cached", @@ -594,7 +724,7 @@ "viewport": "phone-portrait", "pullRequest": 220, "issue": 168, - "sha256": "497e4544313c5b4910d6bec4f77547ed1876cb071305879a87b970275271ce59" + "sha256": "e5c2ecbc65842cd46b12006cc879ac0dfc7787d2cee03e5938d1222a05708da4" }, { "scenario": "transfer-desktop-active", @@ -610,7 +740,7 @@ "viewport": "wide", "pullRequest": 220, "issue": 168, - "sha256": "099c1cbb5c61037b5aba13fe2ca59cf00307aebe98d2bce74f5b14b8252ea69f" + "sha256": "b1c3ba9ed6236345ae8fe8d18024ea2adaed33a861758a1aa52d571d634083a1" }, { "scenario": "transfer-desktop-completed-page", @@ -626,7 +756,7 @@ "viewport": "wide", "pullRequest": 220, "issue": 168, - "sha256": "2d2d6887f26d60551c25e774b0f3bcd4ea46df54d34d697a408d5661bd61d1df" + "sha256": "570b0c78d0d88f73407a7ea827fb6c26a8d1c3f4bc19f2bd05e10a8e38df9a23" }, { "scenario": "deck-board-desktop", @@ -642,7 +772,7 @@ "viewport": "wide", "pullRequest": 221, "issue": 52, - "sha256": "f13fdb332b679bc88f76a2d9393f9da1c529dbb893c6aec490f455bfe44ec9fe" + "sha256": "b41f9a2d8da5653fb1bc174d77f72bf1eda5c10b63e38209ed8f5fc9a17bc019" }, { "scenario": "deck-board-mobile", @@ -658,7 +788,7 @@ "viewport": "phone-portrait", "pullRequest": 221, "issue": 52, - "sha256": "a1cfcc4d620103cb146c3f2f5b2de1f9dd3ffdea6c73e8b31444107537fd6e18" + "sha256": "ea047a30de48e5366edf30e43d142ab8a6491617dcf0051d6f1f932a213133f5" } ] } diff --git a/website/public/screenshots/deck-board-desktop.png b/website/public/screenshots/deck-board-desktop.png index ccadcd9f3..dc8e8db54 100644 Binary files a/website/public/screenshots/deck-board-desktop.png and b/website/public/screenshots/deck-board-desktop.png differ diff --git a/website/public/screenshots/deck-board-mobile.png b/website/public/screenshots/deck-board-mobile.png index b156049be..bf21834f4 100644 Binary files a/website/public/screenshots/deck-board-mobile.png and b/website/public/screenshots/deck-board-mobile.png differ diff --git a/website/public/screenshots/desktop-home.png b/website/public/screenshots/desktop-home.png index 0ffd8c948..ed553b74f 100644 Binary files a/website/public/screenshots/desktop-home.png and b/website/public/screenshots/desktop-home.png differ diff --git a/website/public/screenshots/desktop-startup-settings.png b/website/public/screenshots/desktop-startup-settings.png new file mode 100644 index 000000000..29344847c Binary files /dev/null and b/website/public/screenshots/desktop-startup-settings.png differ diff --git a/website/public/screenshots/file-share-error-mobile.png b/website/public/screenshots/file-share-error-mobile.png index 6321e83d9..3b565cf17 100644 Binary files a/website/public/screenshots/file-share-error-mobile.png and b/website/public/screenshots/file-share-error-mobile.png differ diff --git a/website/public/screenshots/file-share-group-desktop.png b/website/public/screenshots/file-share-group-desktop.png index c9e30b126..d24d7b2a8 100644 Binary files a/website/public/screenshots/file-share-group-desktop.png and b/website/public/screenshots/file-share-group-desktop.png differ diff --git a/website/public/screenshots/file-share-loading-mobile.png b/website/public/screenshots/file-share-loading-mobile.png index 4656affb2..1475a6fd8 100644 Binary files a/website/public/screenshots/file-share-loading-mobile.png and b/website/public/screenshots/file-share-loading-mobile.png differ diff --git a/website/public/screenshots/file-share-user-mobile.png b/website/public/screenshots/file-share-user-mobile.png index 0f5fde515..20881f23c 100644 Binary files a/website/public/screenshots/file-share-user-mobile.png and b/website/public/screenshots/file-share-user-mobile.png differ diff --git a/website/public/screenshots/file-sync-rules-mobile.png b/website/public/screenshots/file-sync-rules-mobile.png new file mode 100644 index 000000000..898880f93 Binary files /dev/null and b/website/public/screenshots/file-sync-rules-mobile.png differ diff --git a/website/public/screenshots/file-sync-selection-desktop.png b/website/public/screenshots/file-sync-selection-desktop.png new file mode 100644 index 000000000..237f45f79 Binary files /dev/null and b/website/public/screenshots/file-sync-selection-desktop.png differ diff --git a/website/public/screenshots/file-sync-selection-mobile.png b/website/public/screenshots/file-sync-selection-mobile.png new file mode 100644 index 000000000..eb2cd717a Binary files /dev/null and b/website/public/screenshots/file-sync-selection-mobile.png differ diff --git a/website/public/screenshots/file-sync-setup-desktop.png b/website/public/screenshots/file-sync-setup-desktop.png new file mode 100644 index 000000000..f1a309256 Binary files /dev/null and b/website/public/screenshots/file-sync-setup-desktop.png differ diff --git a/website/public/screenshots/file-sync-status-desktop.png b/website/public/screenshots/file-sync-status-desktop.png new file mode 100644 index 000000000..0532a8f41 Binary files /dev/null and b/website/public/screenshots/file-sync-status-desktop.png differ diff --git a/website/public/screenshots/file-sync-status-mobile.png b/website/public/screenshots/file-sync-status-mobile.png new file mode 100644 index 000000000..85e1f9483 Binary files /dev/null and b/website/public/screenshots/file-sync-status-mobile.png differ diff --git a/website/public/screenshots/file-sync-tray-linux.png b/website/public/screenshots/file-sync-tray-linux.png new file mode 100644 index 000000000..2f8b0968e Binary files /dev/null and b/website/public/screenshots/file-sync-tray-linux.png differ diff --git a/website/public/screenshots/live-photo-motion-failure-mobile.png b/website/public/screenshots/live-photo-motion-failure-mobile.png index 5e3c867c0..79103f8dd 100644 Binary files a/website/public/screenshots/live-photo-motion-failure-mobile.png and b/website/public/screenshots/live-photo-motion-failure-mobile.png differ diff --git a/website/public/screenshots/media-backup-queue.png b/website/public/screenshots/media-backup-queue.png index 59c2456f9..080e44df7 100644 Binary files a/website/public/screenshots/media-backup-queue.png and b/website/public/screenshots/media-backup-queue.png differ diff --git a/website/public/screenshots/mobile-home.png b/website/public/screenshots/mobile-home.png index 5c8d6cce6..882ee34c7 100644 Binary files a/website/public/screenshots/mobile-home.png and b/website/public/screenshots/mobile-home.png differ diff --git a/website/public/screenshots/native-tiff-preview-mobile.png b/website/public/screenshots/native-tiff-preview-mobile.png index 6d25e001a..4bb5c5caa 100644 Binary files a/website/public/screenshots/native-tiff-preview-mobile.png and b/website/public/screenshots/native-tiff-preview-mobile.png differ diff --git a/website/public/screenshots/obsidian-vault-sync.png b/website/public/screenshots/obsidian-vault-sync.png index ad579a325..121a83c5d 100644 Binary files a/website/public/screenshots/obsidian-vault-sync.png and b/website/public/screenshots/obsidian-vault-sync.png differ diff --git a/website/public/screenshots/photo-folder-browser-desktop.png b/website/public/screenshots/photo-folder-browser-desktop.png index cdf672c92..697a5f56a 100644 Binary files a/website/public/screenshots/photo-folder-browser-desktop.png and b/website/public/screenshots/photo-folder-browser-desktop.png differ diff --git a/website/public/screenshots/photo-folder-browser-mobile.png b/website/public/screenshots/photo-folder-browser-mobile.png index 391c679e1..f00d4234a 100644 Binary files a/website/public/screenshots/photo-folder-browser-mobile.png and b/website/public/screenshots/photo-folder-browser-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-raw-retry-mobile.png b/website/public/screenshots/photo-timeline-raw-retry-mobile.png index c79375b96..34c10784c 100644 Binary files a/website/public/screenshots/photo-timeline-raw-retry-mobile.png and b/website/public/screenshots/photo-timeline-raw-retry-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png b/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png index 75e35418b..53d06abe2 100644 Binary files a/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png and b/website/public/screenshots/photo-timeline-return-to-newest-error-mobile.png differ diff --git a/website/public/screenshots/photo-timeline-revalidation-error-mobile.png b/website/public/screenshots/photo-timeline-revalidation-error-mobile.png index 2863a5d57..e7d4ca56f 100644 Binary files a/website/public/screenshots/photo-timeline-revalidation-error-mobile.png and b/website/public/screenshots/photo-timeline-revalidation-error-mobile.png differ diff --git a/website/public/screenshots/raw-preview-error-mobile.png b/website/public/screenshots/raw-preview-error-mobile.png index 2c589fed6..0c6ed453c 100644 Binary files a/website/public/screenshots/raw-preview-error-mobile.png and b/website/public/screenshots/raw-preview-error-mobile.png differ diff --git a/website/public/screenshots/raw-preview-high-detail-desktop.png b/website/public/screenshots/raw-preview-high-detail-desktop.png index 39e51df2c..640cadd23 100644 Binary files a/website/public/screenshots/raw-preview-high-detail-desktop.png and b/website/public/screenshots/raw-preview-high-detail-desktop.png differ diff --git a/website/public/screenshots/raw-preview-loading-mobile.png b/website/public/screenshots/raw-preview-loading-mobile.png index bad35388c..5ef58e5a0 100644 Binary files a/website/public/screenshots/raw-preview-loading-mobile.png and b/website/public/screenshots/raw-preview-loading-mobile.png differ diff --git a/website/public/screenshots/raw-preview-memories-ready-mobile.png b/website/public/screenshots/raw-preview-memories-ready-mobile.png index 742944d82..68c4cec89 100644 Binary files a/website/public/screenshots/raw-preview-memories-ready-mobile.png and b/website/public/screenshots/raw-preview-memories-ready-mobile.png differ diff --git a/website/public/screenshots/transfer-desktop-active.png b/website/public/screenshots/transfer-desktop-active.png index 31a691051..8d158ed8e 100644 Binary files a/website/public/screenshots/transfer-desktop-active.png and b/website/public/screenshots/transfer-desktop-active.png differ diff --git a/website/public/screenshots/transfer-desktop-completed-page.png b/website/public/screenshots/transfer-desktop-completed-page.png index 422d3deb8..1e4d90b76 100644 Binary files a/website/public/screenshots/transfer-desktop-completed-page.png and b/website/public/screenshots/transfer-desktop-completed-page.png differ diff --git a/website/public/screenshots/transfer-mobile-failed-cached.png b/website/public/screenshots/transfer-mobile-failed-cached.png index 553ccb9d2..0305acc70 100644 Binary files a/website/public/screenshots/transfer-mobile-failed-cached.png and b/website/public/screenshots/transfer-mobile-failed-cached.png differ diff --git a/website/public/screenshots/transfer-mobile-pending.png b/website/public/screenshots/transfer-mobile-pending.png index 15c63f80d..da7d063bd 100644 Binary files a/website/public/screenshots/transfer-mobile-pending.png and b/website/public/screenshots/transfer-mobile-pending.png differ diff --git a/website/public/screenshots/virtual-file-storage-desktop.png b/website/public/screenshots/virtual-file-storage-desktop.png new file mode 100644 index 000000000..1b1ef507b Binary files /dev/null and b/website/public/screenshots/virtual-file-storage-desktop.png differ diff --git a/website/public/screenshots/virtual-file-storage-mobile.png b/website/public/screenshots/virtual-file-storage-mobile.png new file mode 100644 index 000000000..1e8ffc834 Binary files /dev/null and b/website/public/screenshots/virtual-file-storage-mobile.png differ