diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 44ca3963b1..bf3a7df058 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -20,9 +20,9 @@ on: env: PROJECT: Nextcloud.xcodeproj - DESTINATION: platform=iOS Simulator,name=iPhone 17,OS=27.0 + DESTINATION: platform=iOS Simulator,name=iPhone 17,OS=27.2 SCHEME: Nextcloud - XCODE_VERSION: '27.1' + XCODE_VERSION: '27.2' SERVER_BRANCH: stable30 PHP_VERSION: 8.3 @@ -60,7 +60,7 @@ jobs: retention-days: 4 test: - name: Run Tests + name: Run Unit Tests runs-on: xcode-27 timeout-minutes: 40 needs: [build] @@ -69,42 +69,6 @@ jobs: steps: - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Detect passcode changes - id: passcode_changes - shell: bash - env: - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - PUSH_BEFORE_SHA: ${{ github.event.before }} - run: | - run_passcode_tests=false - if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then - compare_base=$(git merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA") - compare_head="$PR_HEAD_SHA" - else - compare_base="$PUSH_BEFORE_SHA" - compare_head="$GITHUB_SHA" - fi - - if [ -z "$compare_base" ] || [ "$compare_base" = "0000000000000000000000000000000000000000" ]; then - run_passcode_tests=true - else - git diff --name-only -z "$compare_base" "$compare_head" > "$RUNNER_TEMP/passcode-changed-files" - while IFS= read -r -d '' changed_file; do - case "$changed_file" in - iOSClient/Utility/Passcode/* | \ - Tests/NextcloudUITests/PasscodeUITests.swift) - run_passcode_tests=true - break - ;; - esac - done < "$RUNNER_TEMP/passcode-changed-files" - fi - echo "run=$run_passcode_tests" >> "$GITHUB_OUTPUT" - echo "Run passcode UI tests: $run_passcode_tests" - name: Setup Xcode uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 @@ -166,33 +130,17 @@ jobs: xcrun simctl boot "iPhone 17" xcrun simctl bootstatus "iPhone 17" -b - - name: Check if Safari is installed - run: | - if xcrun simctl listapps booted | grep -q com.apple.mobilesafari; then - echo "Safari is installed in the simulator." - else - echo "Safari is NOT installed in the simulator." - exit 1 - fi - - - name: Run Xcode Test + - name: Run Xcode Unit Tests timeout-minutes: 25 shell: bash - env: - RUN_PASSCODE_TESTS: ${{ steps.passcode_changes.outputs.run }} run: | set -o pipefail - test_selection=() - if [ "$RUN_PASSCODE_TESTS" != "true" ]; then - test_selection+=(-skip-testing:NextcloudUITests/PasscodeUITests) - fi + # UI tests remain available in the shared scheme for local execution. xcodebuild test-without-building \ -xctestrun $(find . -type f -name "*.xctestrun") \ -destination "${{ env.DESTINATION }}" \ -derivedDataPath "DerivedData" \ - "${test_selection[@]}" \ - -test-iterations 3 \ - -retry-tests-on-failure \ + -only-testing:NextcloudUnitTests \ -test-timeouts-enabled YES \ -default-test-execution-time-allowance 600 \ -maximum-test-execution-time-allowance 600 \ diff --git a/AGENTS.md b/AGENTS.md index 18c62c6bca..7fe8ec2350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,10 @@ Other platforms like Android are irrelevant for this project. | `Brand` | Branding layer: `NCBrand.swift` (app constants, colors), `Database.swift` (Realm database name and schema version), Info.plists and entitlements for all targets, launch screen and intro screens. | Bump `databaseSchemaVersion` in `Brand/Database.swift` when changing Realm models. | | `File Provider Extension` | Contains business logic for the iOS File Provider extension. | — | | `File Provider Extension UI` | Contains UI logic for the iOS File Provider extension. | — | -| `Share` | Contains code for the iOS Share extension. | — | +| `Share` | Contains the active Share extension entry point, launched through `NSExtensionPrincipalClass`, plus input preparation, session storage and recent upload destinations. | — | +| `Share/View` | Contains the SwiftUI Share extension interface. | Select the `Nextcloud` scheme for Xcode previews. Shared Share sources are hosted by the app only in Debug (`SHARE_PREVIEWS`) and excluded from app Release builds. | +| `Share/Model` | Contains Share extension state, account handling and upload logic. | — | +| `Share/UIKit` | Contains the previous UIKit Share extension sources and storyboard for reference. | Keep these reference files out of the Share target build. | | `Widget` | Contains code for iOS Widgets. | — | | `WidgetDashboardIntentHandler` | Contains the intent handler for the dashboard widget. | — | | `Notification Service Extension` | Contains the push notification service extension. | — | diff --git a/BackgroundUploadExtension/NCBackgroundUploadExtensionManager.swift b/BackgroundUploadExtension/NCBackgroundUploadExtensionManager.swift index b69c70d046..7ccff9e408 100644 --- a/BackgroundUploadExtension/NCBackgroundUploadExtensionManager.swift +++ b/BackgroundUploadExtension/NCBackgroundUploadExtensionManager.swift @@ -393,6 +393,11 @@ final class NCBackgroundUploadExtensionManager { /// Disables the PhotoKit extension after the feature or auto upload is turned off and no jobs remain. /// Active metadata defers disabling so cancellations and terminal results can still be reconciled. func disableIfIdle() async -> Bool { + // Foreground cleanup must not trigger the initial Photos permission prompt. + guard PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined else { + return false + } + let featureEnabled = NCPreferences().shouldUseBackgroundUploadExtension let account = await database.getTableAccountAsync(predicate: NSPredicate(format: "autoUploadStart == true")) diff --git a/Brand/Share.plist b/Brand/Share.plist index ec2daea2b1..3bb515ffbe 100755 --- a/Brand/Share.plist +++ b/Brand/Share.plist @@ -25,6 +25,8 @@ NSAllowsArbitraryLoads + NSFaceIDUsageDescription + Face ID is required to authenticate using face recognition. NSExtension NSExtensionAttributes @@ -32,10 +34,10 @@ NSExtensionActivationRule SUBQUERY (extensionItems, $extensionItem, SUBQUERY ($extensionItem.attachments, $attachment, (ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.data" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.text")).@count == $extensionItem.attachments.@count).@count > 0 - NSExtensionMainStoryboard - MainInterface + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NCShareEntryController NSExtensionPointIdentifier com.apple.share-services - \ No newline at end of file + diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 819de00a3f..4095bdc561 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -37,6 +37,8 @@ A1E2EE020000000000000008 /* NCEndToEndKeySetResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */; }; A358B569BF5FDABA6D9012D1 /* NCErrorContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0CD9A00D74B01055F238E9 /* NCErrorContext.swift */; }; A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */; }; + A71107260000000000000101 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.swift */; }; + A71107260000000000000102 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.swift */; }; AA3C85E82D36B08C00F74F12 /* UITestBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */; }; AA3C85EB2D36BBFB00F74F12 /* OCSResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85EA2D36BBF400F74F12 /* OCSResponse.swift */; }; AA3C85EE2D36BCCE00F74F12 /* SharesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85ED2D36BCCB00F74F12 /* SharesResponse.swift */; }; @@ -73,6 +75,35 @@ AAFC0D062F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D032F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift */; }; AAFC0D092F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D082F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift */; }; AAFC0D0B2F9AA10000F0A001 /* NCAutoUploadCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */; }; + AB1028260000000000030000 /* NCShareEntryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020000 /* NCShareEntryController.swift */; }; + AB1028260000000000030002 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; + AB1028260000000000030003 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; + AB1028260000000000030004 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; + AB1028260000000000030006 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; + AB1028260000000000030009 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; + AB102826000000000003000A /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; + AB1028260000000000040001 /* NCShareLoaderItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */; }; + AB1028260000000000040002 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; + AB1028260000000000050200 /* NCShareUploadNamePlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */; }; + AB1028260000000000050201 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; + AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.swift */; }; + AB1028260000000000080000 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; + AB1028260000000000080001 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; + AB1028260000000000080002 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; + AB1028260000000000080003 /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; + AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; + AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.swift */; }; + AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; + AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; + AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; + AB1028260000000000090110 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; + AB1028260000000000090120 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; + AB1028260000000000090121 /* NCShareRecentFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */; }; + AB1109260000000000010101 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; + AB1109260000000000010102 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000002F60000100FE2775 /* NCTagEditorModel.swift */; }; AB6000032F60000200FE2775 /* NCTagEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6000022F60000200FE2775 /* NCTagEditorView.swift */; }; AB7000022F70000100FE2776 /* NCShareDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7000012F70000100FE2776 /* NCShareDetailsView.swift */; }; @@ -83,13 +114,6 @@ AC14ADAF2026101400000012 /* NCMediaViewerPreviewLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC14ADAF2026101400000011 /* NCMediaViewerPreviewLoader.swift */; }; AF1A9B6427D0CA1E00F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; AF1A9B6527D0CC0500F17A9E /* UIAlertController+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */; }; - AF22B206277B4E4C00DAB0CC /* NCCreateFormUploadConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = F704B5E42430AA8000632F5F /* NCCreateFormUploadConflict.swift */; }; - AF22B207277B4E4C00DAB0CC /* NCCreateFormUploadConflict.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F704B5E22430AA6F00632F5F /* NCCreateFormUploadConflict.storyboard */; }; - AF22B208277B4E4C00DAB0CC /* NCCreateFormUploadConflictCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F704B5E82430C0B800632F5F /* NCCreateFormUploadConflictCell.swift */; }; - AF22B209277B4E4C00DAB0CC /* NCCreateFormUploadConflictCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F704B5E62430C06700632F5F /* NCCreateFormUploadConflictCell.xib */; }; - AF22B20C277C6F4D00DAB0CC /* NCShareCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF22B20B277C6F4D00DAB0CC /* NCShareCell.swift */; }; - AF22B217277D196700DAB0CC /* NCShareExtension+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF22B215277D196700DAB0CC /* NCShareExtension+DataSource.swift */; }; - AF22B218277D196700DAB0CC /* NCShareExtension+Files.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF22B216277D196700DAB0CC /* NCShareExtension+Files.swift */; }; AF2D7C7C2742556F00ADF566 /* NCShareLinkCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF2D7C7B2742556F00ADF566 /* NCShareLinkCell.swift */; }; AF2D7C7E2742559100ADF566 /* NCShareUserCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF2D7C7D2742559100ADF566 /* NCShareUserCell.swift */; }; AF3FDCC22796ECC300710F60 /* NCTrash+CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF3FDCC12796ECC300710F60 /* NCTrash+CollectionView.swift */; }; @@ -104,7 +128,6 @@ AF4BF62127562B3F0081CEEF /* NCManageDatabase+Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF4BF61D27562B3F0081CEEF /* NCManageDatabase+Activity.swift */; }; AF56C1DC2784856200D8BAE2 /* NCActivityCommentView.xib in Resources */ = {isa = PBXBuildFile; fileRef = AF56C1DB2784856200D8BAE2 /* NCActivityCommentView.xib */; }; AF730AF827834B1400B7520E /* NCShare+NCCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF730AF727834B1400B7520E /* NCShare+NCCellDelegate.swift */; }; - AF730AFA27843E4C00B7520E /* NCShareExtension+NCAccountRequestDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF730AF927843E4C00B7520E /* NCShareExtension+NCAccountRequestDelegate.swift */; }; AF7E504E27A2D8FF00B5E4AF /* UIBarButton+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF7E504D27A2D8FF00B5E4AF /* UIBarButton+Extension.swift */; }; AF93471227E2341B002537EE /* NCContextMenuShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF93471127E2341B002537EE /* NCContextMenuShare.swift */; }; AF93471927E2361E002537EE /* NCShareAdvancePermissionFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = AF93471427E2361E002537EE /* NCShareAdvancePermissionFooter.xib */; }; @@ -153,12 +176,10 @@ F33EE6E12BF4BDA500CA1A51 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6E02BF4BDA500CA1A51 /* NIOSSL */; }; F33EE6E32BF4C00700CA1A51 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6E22BF4C00700CA1A51 /* NIOSSL */; }; F33EE6E52BF4C02000CA1A51 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6E42BF4C02000CA1A51 /* NIOSSL */; }; - F33EE6E72BF4C02600CA1A51 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6E62BF4C02600CA1A51 /* NIOSSL */; }; F33EE6ED2BF4C03800CA1A51 /* NIOSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6EC2BF4C03800CA1A51 /* NIOSSL */; }; F33EE6F02BF4C0FF00CA1A51 /* NIO in Frameworks */ = {isa = PBXBuildFile; productRef = F33EE6EF2BF4C0FF00CA1A51 /* NIO */; }; F33EE6F22BF4C9B200CA1A51 /* PKCS12.swift in Sources */ = {isa = PBXBuildFile; fileRef = F33EE6F12BF4C9B200CA1A51 /* PKCS12.swift */; }; F343A4B32A1E01FF00DDA874 /* PHAsset+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F343A4B22A1E01FF00DDA874 /* PHAsset+Extension.swift */; }; - F343A4B62A1E084200DDA874 /* PHAsset+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F343A4B22A1E01FF00DDA874 /* PHAsset+Extension.swift */; }; F343A4BB2A1E734600DDA874 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F343A4BA2A1E734600DDA874 /* Optional+Extension.swift */; }; F34BDB3A2F5744EC007A222C /* UINavigationItem+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34BDB392F5744E5007A222C /* UINavigationItem+Extension.swift */; }; F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */; }; @@ -246,7 +267,6 @@ F70716E62987F81500E72C1D /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70716E52987F81500E72C1D /* DocumentActionViewController.swift */; }; F70716ED2987F81500E72C1D /* File Provider Extension UI.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F70716E32987F81500E72C1D /* File Provider Extension UI.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; F707C26521A2DC5200F6181E /* NCStoreReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = F707C26421A2DC5200F6181E /* NCStoreReview.swift */; }; - F70821D829E59E6D001CA2D7 /* TagListView in Frameworks */ = {isa = PBXBuildFile; productRef = F70821D729E59E6D001CA2D7 /* TagListView */; }; F70898672EDDB39B00EF85BD /* NCNetworking+TransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */; }; F70898692EDDB51700EF85BD /* NCSelectOpen+SelectDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898682EDDB51200EF85BD /* NCSelectOpen+SelectDelegate.swift */; }; F70B86752642CE3B00ED5349 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F70B86742642CE3B00ED5349 /* FirebaseCrashlytics */; }; @@ -272,11 +292,9 @@ F711A4DC2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F711A4DB2AF92CAD00095DD8 /* NCUtility+Date.swift */; }; F711A4DD2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F711A4DB2AF92CAD00095DD8 /* NCUtility+Date.swift */; }; F711A4DE2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F711A4DB2AF92CAD00095DD8 /* NCUtility+Date.swift */; }; - F711A4DF2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = F711A4DB2AF92CAD00095DD8 /* NCUtility+Date.swift */; }; F711A4E32AF9310400095DD8 /* NCUtility+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF93474B27E34120002537EE /* NCUtility+Image.swift */; }; F711A4E52AF9310500095DD8 /* NCUtility+Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF93474B27E34120002537EE /* NCUtility+Image.swift */; }; F711A4E92AF9327600095DD8 /* UIImage+animatedGIF.m in Sources */ = {isa = PBXBuildFile; fileRef = F713FEFF2472764100214AF6 /* UIImage+animatedGIF.m */; }; - F711A4EB2AF9327D00095DD8 /* UIImage+animatedGIF.m in Sources */ = {isa = PBXBuildFile; fileRef = F713FEFF2472764100214AF6 /* UIImage+animatedGIF.m */; }; F711D63128F44801003F43C8 /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C9739428F17131002C43E2 /* IntentHandler.swift */; }; F712217E2F51AF9500ACEB08 /* LucidBanner in Frameworks */ = {isa = PBXBuildFile; productRef = F712217D2F51AF9500ACEB08 /* LucidBanner */; }; F71221802F51AF9F00ACEB08 /* LucidBanner in Frameworks */ = {isa = PBXBuildFile; productRef = F712217F2F51AF9F00ACEB08 /* LucidBanner */; }; @@ -285,10 +303,6 @@ F713FF002472764100214AF6 /* UIImage+animatedGIF.m in Sources */ = {isa = PBXBuildFile; fileRef = F713FEFF2472764100214AF6 /* UIImage+animatedGIF.m */; }; F7145610296433C80038D028 /* NCDocumentCamera.swift in Sources */ = {isa = PBXBuildFile; fileRef = F714560F296433C80038D028 /* NCDocumentCamera.swift */; }; F7145A231D12E3B700CAFEEC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F7E70DE91A24DE4100E1B66A /* Localizable.strings */; }; - F714803B262EBE3900693E51 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F714803A262EBE3900693E51 /* MainInterface.storyboard */; }; - F7148041262EBE4000693E51 /* NCShareExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7148040262EBE4000693E51 /* NCShareExtension.swift */; }; - F7148054262ED51000693E51 /* NCListCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F78ACD4321903CF20088454D /* NCListCell.xib */; }; - F714805E262ED52900693E51 /* NCSectionFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = F78ACD53219047D40088454D /* NCSectionFooter.xib */; }; F714A1472ED84AF90050A43B /* HudBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F714A1462ED84AF00050A43B /* HudBannerView.swift */; }; F7160A7D2BE931DE0034DCB3 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F7160A7C2BE931DE0034DCB3 /* RealmSwift */; }; F7160A822BE933390034DCB3 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F7160A812BE933390034DCB3 /* RealmSwift */; }; @@ -348,7 +362,6 @@ F72CD63A25C19EBF00F46F9A /* NCAutoUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72CD63925C19EBF00F46F9A /* NCAutoUpload.swift */; }; F72D1007210B6882009C96B7 /* NCPushNotificationEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = F72D1005210B6882009C96B7 /* NCPushNotificationEncryption.m */; }; F72D404923D2082500A97FD0 /* NCViewerDirectEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72D404823D2082500A97FD0 /* NCViewerDirectEditing.swift */; }; - F72D7EB7263B1207000B3DFC /* MarkdownKit in Frameworks */ = {isa = PBXBuildFile; productRef = F72D7EB6263B1207000B3DFC /* MarkdownKit */; }; F72DA9B425F53E4E00B87DB1 /* SwiftRichString in Frameworks */ = {isa = PBXBuildFile; productRef = F72DA9B325F53E4E00B87DB1 /* SwiftRichString */; }; F72EA95228B7BA2A00C88F0C /* DashboardWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72EA95128B7BA2A00C88F0C /* DashboardWidgetProvider.swift */; }; F72EA95428B7BABA00C88F0C /* FilesWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F72EA95328B7BABA00C88F0C /* FilesWidgetProvider.swift */; }; @@ -378,7 +391,6 @@ F7381EE1218218C9000B1560 /* NCOffline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7381EDA218218C9000B1560 /* NCOffline.swift */; }; F7381EE5218218C9000B1560 /* NCOffline.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7381EDE218218C9000B1560 /* NCOffline.storyboard */; }; F7386E482DA90E0F009A00F6 /* NCAppVersionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7386E452DA90E02009A00F6 /* NCAppVersionManager.swift */; }; - F73BC74F2F23811E003170C2 /* WarningBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DF7B3E2F1A2EE400514020 /* WarningBannerView.swift */; }; F73CB3B222E072A000AD728E /* NCShareHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = F73CB3B122E072A000AD728E /* NCShareHeaderView.xib */; }; F73D11FA253C5F4800DF9BEC /* NCViewerDirectEditing.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F73D11F9253C5F4800DF9BEC /* NCViewerDirectEditing.storyboard */; }; F73EF7A72B0223900087E6E9 /* NCManageDatabase+Comments.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73EF7A62B0223900087E6E9 /* NCManageDatabase+Comments.swift */; }; @@ -407,14 +419,12 @@ F73EF7E22B02266D0087E6E9 /* NCManageDatabase+Trash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73EF7DE2B02266C0087E6E9 /* NCManageDatabase+Trash.swift */; }; F73EF7E52B02266D0087E6E9 /* NCManageDatabase+Trash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73EF7DE2B02266C0087E6E9 /* NCManageDatabase+Trash.swift */; }; F73EFF9B2DB11EC900FD434C /* NCFiles+UIScrollViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F73EFF9A2DB11EB900FD434C /* NCFiles+UIScrollViewDelegate.swift */; }; - F740BEF02A35C2AD00E9B6D5 /* UILabel+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EE66AC2A20B226009AE765 /* UILabel+Extension.swift */; }; F7411C552D7B26D700F57358 /* NCNetworking+ServerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7411C532D7B26C600F57358 /* NCNetworking+ServerError.swift */; }; F741C2242B6B9FD600E849BB /* NCMediaSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F741C2232B6B9FD600E849BB /* NCMediaSelectTabBar.swift */; }; F74230F32C79B57200CA1ACA /* NCNetworking+Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74230F22C79B57200CA1ACA /* NCNetworking+Task.swift */; }; F7434B3820E2400600417916 /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; F743C89E2E5B25A1000173A9 /* UIScene+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F743C89D2E5B2595000173A9 /* UIScene+Extension.swift */; }; F745B253222D88AE00346520 /* NCLoginQRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = F745B252222D88AE00346520 /* NCLoginQRCode.swift */; }; - F746EC51273906C40052598D /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F746EC52273906C40052598D /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F746EC53273906C50052598D /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F747EB0D2C4AC1FF00F959A8 /* NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F747EB0C2C4AC1FF00F959A8 /* NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift */; }; @@ -444,13 +454,10 @@ F74B6D982A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */; }; F74B6D9B2A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */; }; F74B91E52F51D4170050813D /* InfoBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B91E42F51D4100050813D /* InfoBannerView.swift */; }; - F74B91E62F51D4170050813D /* InfoBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B91E42F51D4100050813D /* InfoBannerView.swift */; }; - F74B91E82F51D45A0050813D /* ErrorBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B91E72F51D4510050813D /* ErrorBannerView.swift */; }; F74B91E92F51D45A0050813D /* ErrorBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B91E72F51D4510050813D /* ErrorBannerView.swift */; }; F74BAE172C7E2F4E0028D4FA /* FileProviderDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76673EC22C901F5007ED366 /* FileProviderDomain.swift */; }; F74C0436253F1CDC009762AB /* NCShares.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74C0434253F1CDC009762AB /* NCShares.swift */; }; F74C0437253F1CDC009762AB /* NCShares.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F74C0435253F1CDC009762AB /* NCShares.storyboard */; }; - F74D50362C9856D300BBBF4C /* NCCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C1EEA425053A9C00866ACC /* NCCollectionViewDataSource.swift */; }; F7501C322212E57500FB1415 /* NCMedia.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7501C302212E57400FB1415 /* NCMedia.storyboard */; }; F7501C332212E57500FB1415 /* NCMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7501C312212E57400FB1415 /* NCMedia.swift */; }; F751247C2C42919C00E63DB8 /* NCPhotoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F751247A2C42919C00E63DB8 /* NCPhotoCell.swift */; }; @@ -497,7 +504,6 @@ F7517608307F52E60057C9B0 /* NCMediaViewerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75175CD307F52E60057C9B0 /* NCMediaViewerAppearance.swift */; }; F7517609307F52E60057C9B0 /* NCMediaAdaptiveLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75175D4307F52E60057C9B0 /* NCMediaAdaptiveLayoutView.swift */; }; F751760A307F52E60057C9B0 /* NCImageViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75175AE307F52E60057C9B0 /* NCImageViewerContentView.swift */; }; - F751760B307F53680057C9B0 /* NCMediaViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75175CE307F52E60057C9B0 /* NCMediaViewerTransitionSource.swift */; }; F752BA052E58C05200616A26 /* Maintenance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F752BA042E58C05200616A26 /* Maintenance.swift */; }; F753BA93281FD8020015BFB6 /* EasyTipView in Frameworks */ = {isa = PBXBuildFile; productRef = F753BA92281FD8020015BFB6 /* EasyTipView */; }; F755BD9B20594AC7008C5FBB /* NCService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755BD9A20594AC7008C5FBB /* NCService.swift */; }; @@ -672,7 +678,6 @@ F76BED83306D13DC002E1442 /* NCLocalDatabase+PhotoLibraryAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76BED79306D0000002E1442 /* NCLocalDatabase+PhotoLibraryAsset.swift */; }; F76C26A62850D3A500E42BDF /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7F67BB81A24D27800EE80DA /* Images.xcassets */; }; F76D364628A4F8BF00214537 /* NCActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76D364528A4F8BF00214537 /* NCActivityIndicator.swift */; }; - F76D364728A4F8BF00214537 /* NCActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76D364528A4F8BF00214537 /* NCActivityIndicator.swift */; }; F76D3CF12428B40E005DFA87 /* NCViewerPDFSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76D3CF02428B40E005DFA87 /* NCViewerPDFSearch.swift */; }; F76D3CF32428B94E005DFA87 /* NCViewerPDFSearchCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F76D3CF22428B94E005DFA87 /* NCViewerPDFSearchCell.xib */; }; F76D3CF52428D0C1005DFA87 /* NCViewerPDF.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F76D3CF42428D0C0005DFA87 /* NCViewerPDF.storyboard */; }; @@ -715,7 +720,6 @@ F77BC3ED293E528A005F2B08 /* NCConfigServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77BC3EC293E528A005F2B08 /* NCConfigServer.swift */; }; F77C3F5B2D9BF8CD00F3C471 /* UITabBar+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77C3F5A2D9BF8B500F3C471 /* UITabBar+Extension.swift */; }; F77C97392953131000FDDD09 /* NCCameraRoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77C97382953131000FDDD09 /* NCCameraRoll.swift */; }; - F77C973A2953143A00FDDD09 /* NCCameraRoll.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77C97382953131000FDDD09 /* NCCameraRoll.swift */; }; F77CB6A92AA08053000C3CA4 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = F77CB6A82AA08053000C3CA4 /* OpenSSL */; }; F77DD6A82C5CC093009448FB /* NCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77DD6A72C5CC093009448FB /* NCSession.swift */; }; F77DD6A92C5CC093009448FB /* NCSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77DD6A72C5CC093009448FB /* NCSession.swift */; }; @@ -738,7 +742,6 @@ F7814E972F3B5F170074DA3A /* NCSVGRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7814E952F3B5F170074DA3A /* NCSVGRenderer.swift */; }; F7816EF22C2C3E1F00A52517 /* NCPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7816EF12C2C3E1F00A52517 /* NCPushNotification.swift */; }; F7817CF829801A3500FFBC65 /* Data+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7817CF729801A3500FFBC65 /* Data+Extension.swift */; }; - F7817CFB29801A3500FFBC65 /* Data+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7817CF729801A3500FFBC65 /* Data+Extension.swift */; }; F7817CFF29802D1A00FFBC65 /* NCPushNotificationEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = F72D1005210B6882009C96B7 /* NCPushNotificationEncryption.m */; }; F78295311F962EFA00A572F5 /* NCEndToEndEncryption.m in Sources */ = {isa = PBXBuildFile; fileRef = F70CAE391F8CF31A008125FD /* NCEndToEndEncryption.m */; }; F782FDC424E6933900666099 /* NCUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70BFC7320E0FA7C00C67599 /* NCUtility.swift */; }; @@ -832,18 +835,12 @@ F798F0E225880608000DAFFD /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F798F0EC2588060A000DAFFD /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F799DF822C4B7DCC003410B5 /* NCSectionFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F799DF812C4B7DCC003410B5 /* NCSectionFooter.swift */; }; - F799DF832C4B7DCC003410B5 /* NCSectionFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F799DF812C4B7DCC003410B5 /* NCSectionFooter.swift */; }; F799DF852C4B7E56003410B5 /* NCSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F799DF842C4B7E56003410B5 /* NCSectionHeader.swift */; }; - F799DF862C4B7E56003410B5 /* NCSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F799DF842C4B7E56003410B5 /* NCSectionHeader.swift */; }; F799DF882C4B83CC003410B5 /* NCCollectionViewCommon+EasyTipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F799DF872C4B83CC003410B5 /* NCCollectionViewCommon+EasyTipView.swift */; }; F79A65C32191D90F00FF6DCC /* NCSelect.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F79A65C22191D90F00FF6DCC /* NCSelect.storyboard */; }; F79A65C62191D95E00FF6DCC /* NCSelect.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79A65C52191D95E00FF6DCC /* NCSelect.swift */; }; F79B646026CA661600838ACA /* UIControl+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79B645F26CA661600838ACA /* UIControl+Extension.swift */; }; - F79B646126CA661600838ACA /* UIControl+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79B645F26CA661600838ACA /* UIControl+Extension.swift */; }; F79B869B265E19D40085C0E0 /* NSMutableAttributedString+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79B869A265E19D40085C0E0 /* NSMutableAttributedString+Extension.swift */; }; - F79EC78926316AC4004E59D6 /* NCPopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F702F30725EE5D47008F8E80 /* NCPopupViewController.swift */; }; - F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD51219046DC0088454D /* NCSectionFirstHeader.swift */; }; - F79ED0F22D2FCA6A00A389D9 /* NCRecommendationsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F75D901E2D2BE12E003E740B /* NCRecommendationsCell.xib */; }; F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F79FFB272A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A03E2E2D425A14007AA677 /* NCFavoriteNavigationController.swift */; }; @@ -859,7 +856,6 @@ F7A509252C26BD5D00326106 /* NCCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A509242C26BD5D00326106 /* NCCreate.swift */; }; F7A509262C26D95D00326106 /* RealmSwift in Embed Frameworks */ = {isa = PBXBuildFile; productRef = F7160A812BE933390034DCB3 /* RealmSwift */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F7A560422AE1593700BE8FD6 /* NCSaveLivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A560412AE1593700BE8FD6 /* NCSaveLivePhoto.swift */; }; - F7A573692E190387009C9257 /* NCShareExtensionData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A573682E190377009C9257 /* NCShareExtensionData.swift */; }; F7A76DC8256A71CD00119AB3 /* UIImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B7504A2397D38E004E13EC /* UIImage+Extension.swift */; }; F7A7FDDD2C2DBD6200E9A93A /* NCDeepLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A7FDDB2C2DBD6200E9A93A /* NCDeepLinkHandler.swift */; }; F7A846DE2BB01ACB0024816F /* NCTrashCellProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A846DD2BB01ACB0024816F /* NCTrashCellProtocol.swift */; }; @@ -884,7 +880,6 @@ F7AE00F8230E81CB007ACF8A /* NCBrowserWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F7230E81CB007ACF8A /* NCBrowserWeb.swift */; }; F7AE00FA230E81EB007ACF8A /* NCBrowserWeb.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7AE00F9230E81EB007ACF8A /* NCBrowserWeb.storyboard */; }; F7B398422A6A91D5007538D6 /* NCSectionFirstHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7B398412A6A91D5007538D6 /* NCSectionFirstHeader.xib */; }; - F7B398432A6A91D5007538D6 /* NCSectionFirstHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7B398412A6A91D5007538D6 /* NCSectionFirstHeader.xib */; }; F7B6B70427C4E7FA00A7F6EB /* NCScan+CollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B6B70327C4E7FA00A7F6EB /* NCScan+CollectionView.swift */; }; F7B7504B2397D38F004E13EC /* UIImage+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B7504A2397D38E004E13EC /* UIImage+Extension.swift */; }; F7B769A82B7A0B2000C1AAEB /* NCManageDatabase+Metadata+Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7B769A72B7A0B2000C1AAEB /* NCManageDatabase+Metadata+Session.swift */; }; @@ -923,7 +918,6 @@ F7C30DFA291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DF9291BCF790017149B /* NCNetworkingE2EECreateFolder.swift */; }; F7C30DFB291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DF9291BCF790017149B /* NCNetworkingE2EECreateFolder.swift */; }; F7C30DFD291BD0B80017149B /* NCNetworkingE2EEDelete.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DFC291BD0B80017149B /* NCNetworkingE2EEDelete.swift */; }; - F7C30DFE291BD0B80017149B /* NCNetworkingE2EEDelete.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DFC291BD0B80017149B /* NCNetworkingE2EEDelete.swift */; }; F7C30E00291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DFF291BD2610017149B /* NCNetworkingE2EERename.swift */; }; F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C30DFF291BD2610017149B /* NCNetworkingE2EERename.swift */; }; F7C55C512FB4A658004A974F /* NCAssistantInputModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C55C502FB4A651004A974F /* NCAssistantInputModel.swift */; }; @@ -961,7 +955,6 @@ F7C9B9232B582F550064EA91 /* NCManageDatabase+SecurityGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C9B91C2B582F550064EA91 /* NCManageDatabase+SecurityGuard.swift */; }; F7CADEFD2EA159210057849E /* NCMetadataUploadTranfersSuccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CADEFA2EA1591D0057849E /* NCMetadataUploadTranfersSuccess.swift */; }; F7CAFE182F164B9500DB35A5 /* NCCollectionViewCommon+CellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CAFE172F164B9200DB35A5 /* NCCollectionViewCommon+CellDelegate.swift */; }; - F7CAFE192F168F6000DB35A5 /* NCDebouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A3DB8F2DDE238C008F7EC8 /* NCDebouncer.swift */; }; F7CAFE1B2F16AA8D00DB35A5 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CAFE1A2F16AA8600DB35A5 /* main.swift */; }; F7CAFE1D2F17A35F00DB35A5 /* ProgressQuantizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CAFE1C2F17A34F00DB35A5 /* ProgressQuantizer.swift */; }; F7CAFE1E2F17A37C00DB35A5 /* ProgressQuantizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CAFE1C2F17A34F00DB35A5 /* ProgressQuantizer.swift */; }; @@ -973,9 +966,7 @@ F7CB77642F5843E500DE649A /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CB77632F5843D700DE649A /* UIFont+Extension.swift */; }; F7CB77652F58463E00DE649A /* UIFont+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CB77632F5843D700DE649A /* UIFont+Extension.swift */; }; F7CBC1232BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7CBC1212BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib */; }; - F7CBC1242BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7CBC1212BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib */; }; F7CBC1252BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; - F7CBC1262BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; F7CCAB512ECF316700F8E68B /* NCCollectionViewCommon+SyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */; }; F7CEE6002BA9A5C9003EFD89 /* NCTrashGridCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7CEE5FE2BA9A5C9003EFD89 /* NCTrashGridCell.xib */; }; F7CEE6012BA9A5C9003EFD89 /* NCTrashGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CEE5FF2BA9A5C9003EFD89 /* NCTrashGridCell.swift */; }; @@ -986,7 +977,6 @@ F7CF06872E1127460063AD04 /* NCManageDatabase+CreateMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CF06822E11273F0063AD04 /* NCManageDatabase+CreateMetadata.swift */; }; F7CF06882E1127460063AD04 /* NCManageDatabase+CreateMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CF06822E11273F0063AD04 /* NCManageDatabase+CreateMetadata.swift */; }; F7D1612023CF19E30039EBBF /* NCViewerRichWorkspace.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7D1611F23CF19E30039EBBF /* NCViewerRichWorkspace.storyboard */; }; - F7D496FC2EBFA541004F9823 /* NCRecommendationsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75D90202D2BE26C003E740B /* NCRecommendationsCell.swift */; }; F7D496FD2EBFA6D9004F9823 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A0D1342591FBC5008F8A13 /* String+Extension.swift */; }; F7D497082EBFAFD6004F9823 /* NCImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B649B2ADFFAED00014640 /* NCImageCache.swift */; }; F7D4BF012CA1831900A5E746 /* NCCollectionViewCommonPinchGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7D4BF002CA1831600A5E746 /* NCCollectionViewCommonPinchGesture.swift */; }; @@ -1016,7 +1006,6 @@ F7D7A7712DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7D7A76B2DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift */; }; F7D7A7722DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7D7A76B2DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift */; }; F7D890752BD25C570050B8A6 /* NCCollectionViewCommon+DragDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7D890742BD25C570050B8A6 /* NCCollectionViewCommon+DragDrop.swift */; }; - F7DA0F662F66AA0D0033020C /* ShowBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DA0F652F66AA0A0033020C /* ShowBanner.swift */; }; F7DA0F672F66AA0D0033020C /* ShowBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DA0F652F66AA0A0033020C /* ShowBanner.swift */; }; F7DF7B3F2F1A2EF900514020 /* WarningBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DF7B3E2F1A2EE400514020 /* WarningBannerView.swift */; }; F7DF7B422F1A36C100514020 /* HelperBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7DF7B412F1A36B600514020 /* HelperBanner.swift */; }; @@ -1054,8 +1043,6 @@ F7E98C1727E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */; }; F7E98C1927E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */; }; F7ED547C25EEA65400956C55 /* QRCodeReader in Frameworks */ = {isa = PBXBuildFile; productRef = F7ED547B25EEA65400956C55 /* QRCodeReader */; }; - F7EDE4D6262D7B9600414FE6 /* NCListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD4121903CE00088454D /* NCListCell.swift */; }; - F7EDE4DB262D7BA200414FE6 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7EDE508262DA9D600414FE6 /* NCSelectCommandViewSelect.xib */; }; F7EDE514262DC2CD00414FE6 /* NCSelectCommandViewSelect+CreateFolder.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7EDE513262DC2CD00414FE6 /* NCSelectCommandViewSelect+CreateFolder.xib */; }; F7EDE51B262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7EDE51A262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib */; }; @@ -1083,10 +1070,7 @@ F7FDFF6D2E437E55000D7688 /* NCAccountSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF562E437E55000D7688 /* NCAccountSettingsModel.swift */; }; F7FDFF6E2E437E55000D7688 /* NCAccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF572E437E55000D7688 /* NCAccountSettingsView.swift */; }; F7FDFF6F2E437E55000D7688 /* NCAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF592E437E55000D7688 /* NCAccount.swift */; }; - F7FDFF702E437E55000D7688 /* NCAccountRequest.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7FDFF512E437E55000D7688 /* NCAccountRequest.storyboard */; }; - F7FDFF722E437E55000D7688 /* NCAccountRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF522E437E55000D7688 /* NCAccountRequest.swift */; }; F7FF2CB12842159500EBB7A1 /* NCSectionHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7FF2CB02842159500EBB7A1 /* NCSectionHeader.xib */; }; - F7FFFCA02FB300440015441E /* NCAssistantSharedTextStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FFFC9D2FB300440015441E /* NCAssistantSharedTextStore.swift */; }; F7FFFCA22FB300600015441E /* NCAssistantSharedTextStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FFFC9D2FB300440015441E /* NCAssistantSharedTextStore.swift */; }; /* End PBXBuildFile section */ @@ -1321,6 +1305,7 @@ A1E2EE020000000000000001 /* NCEndToEndKeySetAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetAccess.swift; sourceTree = ""; }; A1E2EE020000000000000002 /* NCEndToEndKeySetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolver.swift; sourceTree = ""; }; A1E2EE020000000000000007 /* NCEndToEndKeySetResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndKeySetResolverTests.swift; sourceTree = ""; }; + A71107260000000000000100 /* NCShareFilenameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareFilenameView.swift; sourceTree = ""; }; AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestBackend.swift; sourceTree = ""; }; AA3C85EA2D36BBF400F74F12 /* OCSResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OCSResponse.swift; sourceTree = ""; }; AA3C85ED2D36BCCB00F74F12 /* SharesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharesResponse.swift; sourceTree = ""; }; @@ -1414,6 +1399,21 @@ AAFC0D032F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadScreenDimmer.swift; sourceTree = ""; }; AAFC0D082F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFocusedAutoUploadCloudAnimation.swift; sourceTree = ""; }; AAFC0D0A2F9AA10000F0A001 /* NCAutoUploadCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAutoUploadCounter.swift; sourceTree = ""; }; + AB1028260000000000020000 /* NCShareEntryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareEntryController.swift; sourceTree = ""; }; + AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Accounts.swift"; sourceTree = ""; }; + AB1028260000000000020003 /* NCShareViewModel+Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Folders.swift"; sourceTree = ""; }; + AB1028260000000000020004 /* NCShareViewModel+Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Upload.swift"; sourceTree = ""; }; + AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLoaderItemProvider.swift; sourceTree = ""; }; + AB1028260000000000020008 /* NCShareSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareSessionStore.swift; sourceTree = ""; }; + AB1028260000000000020009 /* NCShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareView.swift; sourceTree = ""; }; + AB102826000000000002000A /* NCShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareViewModel.swift; sourceTree = ""; }; + AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLoaderItemProviderTests.swift; sourceTree = ""; }; + AB1028260000000000050001 /* NCShareUploadConflictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadConflictView.swift; sourceTree = ""; }; + AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadNamePlannerTests.swift; sourceTree = ""; }; + AB1028260000000000060000 /* NCShareAccountMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountMenu.swift; sourceTree = ""; }; + AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStore.swift; sourceTree = ""; }; + AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStoreTests.swift; sourceTree = ""; }; + AB1109260000000000010001 /* NCShareUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadButton.swift; sourceTree = ""; }; AB6000002F60000100FE2775 /* NCTagEditorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorModel.swift; sourceTree = ""; }; AB6000022F60000200FE2775 /* NCTagEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTagEditorView.swift; sourceTree = ""; }; AB7000012F70000100FE2776 /* NCShareDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShareDetailsView.swift; sourceTree = ""; }; @@ -1423,9 +1423,6 @@ AC14ADAF2026101400000001 /* NCMediaAdaptiveControlsLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaAdaptiveControlsLayout.swift; sourceTree = ""; }; AC14ADAF2026101400000011 /* NCMediaViewerPreviewLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPreviewLoader.swift; sourceTree = ""; }; AF1A9B6327D0CA1E00F17A9E /* UIAlertController+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Extension.swift"; sourceTree = ""; }; - AF22B20B277C6F4D00DAB0CC /* NCShareCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCell.swift; sourceTree = ""; }; - AF22B215277D196700DAB0CC /* NCShareExtension+DataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCShareExtension+DataSource.swift"; sourceTree = ""; }; - AF22B216277D196700DAB0CC /* NCShareExtension+Files.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCShareExtension+Files.swift"; sourceTree = ""; }; AF2D7C7B2742556F00ADF566 /* NCShareLinkCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLinkCell.swift; sourceTree = ""; }; AF2D7C7D2742559100ADF566 /* NCShareUserCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUserCell.swift; sourceTree = ""; }; AF3FDCC12796ECC300710F60 /* NCTrash+CollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCTrash+CollectionView.swift"; sourceTree = ""; }; @@ -1434,7 +1431,6 @@ AF4BF61D27562B3F0081CEEF /* NCManageDatabase+Activity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Activity.swift"; sourceTree = ""; }; AF56C1DB2784856200D8BAE2 /* NCActivityCommentView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCActivityCommentView.xib; sourceTree = ""; }; AF730AF727834B1400B7520E /* NCShare+NCCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShare+NCCellDelegate.swift"; sourceTree = ""; }; - AF730AF927843E4C00B7520E /* NCShareExtension+NCAccountRequestDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareExtension+NCAccountRequestDelegate.swift"; sourceTree = ""; }; AF7E504D27A2D8FF00B5E4AF /* UIBarButton+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButton+Extension.swift"; sourceTree = ""; }; AF8ED1F92757821000B8DBC4 /* NextcloudUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; AF93471127E2341B002537EE /* NCContextMenuShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuShare.swift; sourceTree = ""; }; @@ -1548,8 +1544,6 @@ F713FEFE2472764000214AF6 /* UIImage+animatedGIF.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIImage+animatedGIF.h"; sourceTree = ""; }; F713FEFF2472764100214AF6 /* UIImage+animatedGIF.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+animatedGIF.m"; sourceTree = ""; }; F714560F296433C80038D028 /* NCDocumentCamera.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCDocumentCamera.swift; sourceTree = ""; }; - F714803A262EBE3900693E51 /* MainInterface.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; - F7148040262EBE4000693E51 /* NCShareExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShareExtension.swift; sourceTree = ""; }; F7148046262EBE4B00693E51 /* Share-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Share-Bridging-Header.h"; sourceTree = ""; }; F714A1462ED84AF00050A43B /* HudBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HudBannerView.swift; sourceTree = ""; }; F7151A811D477A4B00E6AF45 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -1901,7 +1895,6 @@ F7A48414297028FC00BD1B49 /* Nextcloud Hub.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Nextcloud Hub.png"; sourceTree = SOURCE_ROOT; }; F7A509242C26BD5D00326106 /* NCCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCreate.swift; sourceTree = ""; }; F7A560412AE1593700BE8FD6 /* NCSaveLivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCSaveLivePhoto.swift; sourceTree = ""; }; - F7A573682E190377009C9257 /* NCShareExtensionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareExtensionData.swift; sourceTree = ""; }; F7A7FDDB2C2DBD6200E9A93A /* NCDeepLinkHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCDeepLinkHandler.swift; sourceTree = ""; }; F7A846DD2BB01ACB0024816F /* NCTrashCellProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashCellProtocol.swift; sourceTree = ""; }; F7AA41B827C7CF4600494705 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2180,12 +2173,9 @@ F710FC80277B7D2700AA9FBF /* RealmSwift in Frameworks */, F70557B92ED44E4700135623 /* LucidBanner in Frameworks */, F72AD70F28C24BA1006CB92D /* NextcloudKit in Frameworks */, - F33EE6E72BF4C02600CA1A51 /* NIOSSL in Frameworks */, F77CB6A92AA08053000C3CA4 /* OpenSSL in Frameworks */, F760DE092AE66ED00027D78A /* KeychainAccess in Frameworks */, - F70821D829E59E6D001CA2D7 /* TagListView in Frameworks */, F7F623B72A5EFA0C0022D3D4 /* Gzip in Frameworks */, - F72D7EB7263B1207000B3DFC /* MarkdownKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2345,6 +2335,9 @@ F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, 439616AAA50F4360A761E5EF /* NCAlbumsPersistenceTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, + AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */, + AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */, + AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */, 4B74B947E07444CC9CFE0A6C /* NCDocumentEditorSupportTests.swift */, ); path = NextcloudUnitTests; @@ -2369,6 +2362,29 @@ path = Extensions; sourceTree = ""; }; + AB1028260000000000070000 /* View */ = { + isa = PBXGroup; + children = ( + AB1028260000000000020009 /* NCShareView.swift */, + AB1028260000000000060000 /* NCShareAccountMenu.swift */, + AB1028260000000000050001 /* NCShareUploadConflictView.swift */, + AB1109260000000000010001 /* NCShareUploadButton.swift */, + A71107260000000000000100 /* NCShareFilenameView.swift */, + ); + path = View; + sourceTree = ""; + }; + AB1028260000000000070001 /* Model */ = { + isa = PBXGroup; + children = ( + AB102826000000000002000A /* NCShareViewModel.swift */, + AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */, + AB1028260000000000020003 /* NCShareViewModel+Folders.swift */, + AB1028260000000000020004 /* NCShareViewModel+Upload.swift */, + ); + path = Model; + sourceTree = ""; + }; AF93471327E235EB002537EE /* Advanced */ = { isa = PBXGroup; children = ( @@ -3604,13 +3620,12 @@ F7C0F46D1C8880540059EC54 /* Share */ = { isa = PBXGroup; children = ( - F714803A262EBE3900693E51 /* MainInterface.storyboard */, - F7148040262EBE4000693E51 /* NCShareExtension.swift */, - F7A573682E190377009C9257 /* NCShareExtensionData.swift */, - AF730AF927843E4C00B7520E /* NCShareExtension+NCAccountRequestDelegate.swift */, - AF22B215277D196700DAB0CC /* NCShareExtension+DataSource.swift */, - AF22B216277D196700DAB0CC /* NCShareExtension+Files.swift */, - AF22B20B277C6F4D00DAB0CC /* NCShareCell.swift */, + AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */, + AB1028260000000000020000 /* NCShareEntryController.swift */, + AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */, + AB1028260000000000020008 /* NCShareSessionStore.swift */, + AB1028260000000000070000 /* View */, + AB1028260000000000070001 /* Model */, F7148046262EBE4B00693E51 /* Share-Bridging-Header.h */, ); path = Share; @@ -4058,14 +4073,11 @@ ); name = Share; packageProductDependencies = ( - F72D7EB6263B1207000B3DFC /* MarkdownKit */, F710FC7F277B7D2700AA9FBF /* RealmSwift */, F72AD70E28C24BA1006CB92D /* NextcloudKit */, - F70821D729E59E6D001CA2D7 /* TagListView */, F7F623B62A5EFA0C0022D3D4 /* Gzip */, F77CB6A82AA08053000C3CA4 /* OpenSSL */, F760DE082AE66ED00027D78A /* KeychainAccess */, - F33EE6E62BF4C02600CA1A51 /* NIOSSL */, F70557B82ED44E4700135623 /* LucidBanner */, ); productName = "Share Ext"; @@ -4467,21 +4479,11 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F714803B262EBE3900693E51 /* MainInterface.storyboard in Resources */, - F7148054262ED51000693E51 /* NCListCell.xib in Resources */, F7E4022C2BA85D1D007E5609 /* PrivacyInfo.xcprivacy in Resources */, - AF22B209277B4E4C00DAB0CC /* NCCreateFormUploadConflictCell.xib in Resources */, - F7CBC1242BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */, - F7B398432A6A91D5007538D6 /* NCSectionFirstHeader.xib in Resources */, - AF22B207277B4E4C00DAB0CC /* NCCreateFormUploadConflict.storyboard in Resources */, AA517B822D660EFE00F8D37C /* Localizable.stringsdict in Resources */, F7145A231D12E3B700CAFEEC /* Localizable.strings in Resources */, - F746EC51273906C40052598D /* NCViewCertificateDetails.storyboard in Resources */, F76C26A62850D3A500E42BDF /* Images.xcassets in Resources */, - F714805E262ED52900693E51 /* NCSectionFooter.xib in Resources */, F700222D1EC479840080073F /* Custom.xcassets in Resources */, - F7FDFF702E437E55000D7688 /* NCAccountRequest.storyboard in Resources */, - F79ED0F22D2FCA6A00A389D9 /* NCRecommendationsCell.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4717,6 +4719,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1028260000000000090120 /* NCShareRecentFoldersStore.swift in Sources */, + AB1028260000000000090121 /* NCShareRecentFoldersStoreTests.swift in Sources */, BAF64949B487A080A98F83F2 /* NKErrorUserFriendlyMessageTests.swift in Sources */, AC13ADAF2026101300000002 /* NCMediaAdaptiveLayoutViewTests.swift in Sources */, C0DEDA7E30C1000100A00002 /* NCAutoUploadDateTests.swift in Sources */, @@ -4732,6 +4736,10 @@ F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, 63563FE53573412998852DD3 /* NCAlbumsPersistenceTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, + AB1028260000000000050200 /* NCShareUploadNamePlannerTests.swift in Sources */, + AB1028260000000000050201 /* NCShareViewModel+Upload.swift in Sources */, + AB1028260000000000040001 /* NCShareLoaderItemProviderTests.swift in Sources */, + AB1028260000000000040002 /* NCShareLoaderItemProvider.swift in Sources */, 22BA14432CF44BDBA2E6C291 /* NCDocumentEditorSupportTests.swift in Sources */, F372087D2BAB4C0F006B5430 /* TestConstants.swift in Sources */, F78E2D6C29AF02DB0024D4F3 /* Database.swift in Sources */, @@ -4812,6 +4820,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */, 2EF7D3FD0885CE182717578E /* NKError+UserFriendlyMessage.swift in Sources */, EB511D22564B7B9A605E70AE /* NCErrorContext.swift in Sources */, F7864ACF2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift in Sources */, @@ -4819,36 +4828,24 @@ F7245925289BB59100474787 /* ThreadSafeDictionary.swift in Sources */, F72EC7292F4617F600A2135C /* NotificationCenter+Extension.swift in Sources */, F7BF9D852934CA21009EE9A6 /* NCManageDatabase+LayoutForView.swift in Sources */, - F79EC78926316AC4004E59D6 /* NCPopupViewController.swift in Sources */, F7C30DFB291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */, F73EF7DA2B0226080087E6E9 /* NCManageDatabase+Tip.swift in Sources */, F70557BF2ED44F1800135623 /* UploadBannerView.swift in Sources */, - F7817CFB29801A3500FFBC65 /* Data+Extension.swift in Sources */, - F74B91E82F51D45A0050813D /* ErrorBannerView.swift in Sources */, AF4BF61F27562B3F0081CEEF /* NCManageDatabase+Activity.swift in Sources */, F760A48B2FE95D06001B212E /* NCTransferDelegateDispatcher.swift in Sources */, - F7CBC1262BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */, F7A0D1362591FBC5008F8A13 /* String+Extension.swift in Sources */, F7236DDE2F7C00E600F9D722 /* HudIndeterminateBannerView.swift in Sources */, - F7EDE4D6262D7B9600414FE6 /* NCListCell.swift in Sources */, F764C3E12FFB7DFA00029FD5 /* NCManageDatabase+MediaMetadataBackfill.swift in Sources */, F74B6D982A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */, F7CF06872E1127460063AD04 /* NCManageDatabase+CreateMetadata.swift in Sources */, - F7FDFF722E437E55000D7688 /* NCAccountRequest.swift in Sources */, F7CAFE202F17A37C00DB35A5 /* ProgressQuantizer.swift in Sources */, - F343A4B62A1E084200DDA874 /* PHAsset+Extension.swift in Sources */, F70BFC7520E0FA7D00C67599 /* NCUtility.swift in Sources */, F7E250002FE1000000000003 /* NCDocumentEditorSupport.swift in Sources */, F71DFA222F59FB9A003A93C5 /* View+Extension.swift in Sources */, - AF22B20C277C6F4D00DAB0CC /* NCShareCell.swift in Sources */, F7B769AB2B7A0B2000C1AAEB /* NCManageDatabase+Metadata+Session.swift in Sources */, F7E98C1727E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */, F76D84B63063C2170081F798 /* NCManageDatabase+Albums.swift in Sources */, F76340F82EBDE9760056F538 /* NCManageDatabaseCore.swift in Sources */, - F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */, - F79B646126CA661600838ACA /* UIControl+Extension.swift in Sources */, - F77C973A2953143A00FDDD09 /* NCCameraRoll.swift in Sources */, - F740BEF02A35C2AD00E9B6D5 /* UILabel+Extension.swift in Sources */, F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */, F75F4BC22FD008D7009E55ED /* Optional+Extension.swift in Sources */, AF4BF61A27562A4B0081CEEF /* NCManageDatabase+Metadata.swift in Sources */, @@ -4858,23 +4855,15 @@ F78E2D6829AF02DB0024D4F3 /* Database.swift in Sources */, F7DF7B432F1A373E00514020 /* HelperBanner.swift in Sources */, AA8D31562D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift in Sources */, - F711A4DF2AF92CAE00095DD8 /* NCUtility+Date.swift in Sources */, F78295311F962EFA00A572F5 /* NCEndToEndEncryption.m in Sources */, - F7C30DFE291BD0B80017149B /* NCNetworkingE2EEDelete.swift in Sources */, F7C630802FFE4F8000257EEB /* UIWindowScene+Extension.swift in Sources */, F7D61EA92EBF1694007F865B /* NCManageDatabase+TableCapabilities.swift in Sources */, F71916122E2901FB00E13E96 /* NCNetworking+Upload.swift in Sources */, - F73BC74F2F23811E003170C2 /* WarningBannerView.swift in Sources */, - F7CAFE192F168F6000DB35A5 /* NCDebouncer.swift in Sources */, F77E8C252E79717D00EAE68F /* NCManageDatabase+LivePhoto.swift in Sources */, F79792482F5EECE100FE9544 /* Font+Extension.swift in Sources */, - F751760B307F53680057C9B0 /* NCMediaViewerTransitionSource.swift in Sources */, F7D61EBF2EBF1878007F865B /* NCManageDatabase+Tag.swift in Sources */, F74AF3A5247FB6AE00AC767B /* NCUtilityFileSystem.swift in Sources */, AF1A9B6527D0CC0500F17A9E /* UIAlertController+Extension.swift in Sources */, - AF22B206277B4E4C00DAB0CC /* NCCreateFormUploadConflict.swift in Sources */, - F74D50362C9856D300BBBF4C /* NCCollectionViewDataSource.swift in Sources */, - F7A573692E190387009C9257 /* NCShareExtensionData.swift in Sources */, F7BDC1D4300F440A00C5D9FA /* NCManageDatabase+MediaPreviewBackfill.swift in Sources */, F7BD71E62636EAFC00643C34 /* NCNetworkingE2EE.swift in Sources */, A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */, @@ -4882,21 +4871,13 @@ F7F878AF1FB9E3B900599E4F /* NCEndToEndMetadata.swift in Sources */, F7327E3B2B73B8D600A462C7 /* Array+Extension.swift in Sources */, F7D7A76C2DCDD437003D2007 /* NCManageDatabase+AutoUpload.swift in Sources */, - F7FFFCA02FB300440015441E /* NCAssistantSharedTextStore.swift in Sources */, F39170AF2CB82024006127BC /* FileAutoRenamer+Extensions.swift in Sources */, - F7D496FC2EBFA541004F9823 /* NCRecommendationsCell.swift in Sources */, - F799DF832C4B7DCC003410B5 /* NCSectionFooter.swift in Sources */, - AF22B218277D196700DAB0CC /* NCShareExtension+Files.swift in Sources */, - F799DF862C4B7E56003410B5 /* NCSectionHeader.swift in Sources */, F702F2D025EE5B5C008F8E80 /* NCGlobal.swift in Sources */, F72437802C10B92400C7C68D /* NCSharePermissions.swift in Sources */, - F7EDE4DB262D7BA200414FE6 /* NCCellMain.swift in Sources */, F72944F62A8424F800246839 /* NCEndToEndMetadataV1.swift in Sources */, F73EF7BA2B0224AB0087E6E9 /* NCManageDatabase+ExternalSites.swift in Sources */, - F711A4EB2AF9327D00095DD8 /* UIImage+animatedGIF.m in Sources */, F36E64FA2B96236C0085ABB5 /* UIView+Extension.swift in Sources */, F75A9EE723796C6F0044CFCE /* NCNetworking.swift in Sources */, - AF730AFA27843E4C00B7520E /* NCShareExtension+NCAccountRequestDelegate.swift in Sources */, F7CB77652F58463E00DE649A /* UIFont+Extension.swift in Sources */, F7327E232B73A42F00A462C7 /* NCNetworking+Download.swift in Sources */, F7D497082EBFAFD6004F9823 /* NCImageCache.swift in Sources */, @@ -4920,16 +4901,22 @@ F79FFB272A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */, F7D68FCE28CB9051009139F3 /* NCManageDatabase+DashboardWidget.swift in Sources */, F3F442F12DDE2A7700FD701F /* NCMetadataPermissions.swift in Sources */, - AF22B217277D196700DAB0CC /* NCShareExtension+DataSource.swift in Sources */, F73EF7E22B02266D0087E6E9 /* NCManageDatabase+Trash.swift in Sources */, F77DD6AB2C5CC093009448FB /* NCSession.swift in Sources */, - F74B91E62F51D4170050813D /* InfoBannerView.swift in Sources */, - F76D364728A4F8BF00214537 /* NCActivityIndicator.swift in Sources */, F749B654297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */, - AF22B208277B4E4C00DAB0CC /* NCCreateFormUploadConflictCell.swift in Sources */, - F7DA0F662F66AA0D0033020C /* ShowBanner.swift in Sources */, F73EF7C22B02250B0087E6E9 /* NCManageDatabase+GPS.swift in Sources */, - F7148041262EBE4000693E51 /* NCShareExtension.swift in Sources */, + AB1028260000000000030000 /* NCShareEntryController.swift in Sources */, + AB1028260000000000030002 /* NCShareViewModel+Accounts.swift in Sources */, + AB1028260000000000030003 /* NCShareViewModel+Folders.swift in Sources */, + AB1028260000000000030004 /* NCShareViewModel+Upload.swift in Sources */, + AB1028260000000000030006 /* NCShareLoaderItemProvider.swift in Sources */, + AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */, + AB1028260000000000030009 /* NCShareView.swift in Sources */, + AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */, + AB1109260000000000010101 /* NCShareUploadButton.swift in Sources */, + A71107260000000000000101 /* NCShareFilenameView.swift in Sources */, + AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */, + AB102826000000000003000A /* NCShareViewModel.swift in Sources */, F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */, F76B3CCF1EAE01BD00921AC9 /* NCBrand.swift in Sources */, F72944F32A84246400246839 /* NCEndToEndMetadataV2.swift in Sources */, @@ -5060,6 +5047,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1028260000000000090110 /* NCShareRecentFoldersStore.swift in Sources */, + AB1028260000000000080000 /* NCShareViewModel+Accounts.swift in Sources */, + AB1028260000000000080001 /* NCShareViewModel+Folders.swift in Sources */, + AB1028260000000000080002 /* NCShareViewModel+Upload.swift in Sources */, + AB1028260000000000080003 /* NCShareViewModel.swift in Sources */, + AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */, + AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */, + AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */, + AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */, + AB1109260000000000010102 /* NCShareUploadButton.swift in Sources */, + A71107260000000000000102 /* NCShareFilenameView.swift in Sources */, + AB1028260000000000080008 /* NCShareView.swift in Sources */, 822D68B06B101563A759AD59 /* NKError+UserFriendlyMessage.swift in Sources */, A358B569BF5FDABA6D9012D1 /* NCErrorContext.swift in Sources */, F76BED77306D0000002E1442 /* NCLocalDatabase.swift in Sources */, @@ -6439,6 +6438,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SHARE_PREVIEWS"; SWIFT_OBJC_BRIDGING_HEADER = "$(SRCROOT)/iOSClient/Nextcloud-Bridging-Header.h"; _EXPERIMENTAL_SWIFT_EXPLICIT_MODULES = NO; }; @@ -6452,6 +6452,21 @@ ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Brand/iOSClient.entitlements"; DEAD_CODE_STRIPPING = YES; + EXCLUDED_SOURCE_FILE_NAMES = ( + NCShareRecentFoldersStore.swift, + "$(inherited)", + "NCShareViewModel+Accounts.swift", + "NCShareViewModel+Folders.swift", + "NCShareViewModel+Upload.swift", + NCShareViewModel.swift, + NCShareLoaderItemProvider.swift, + NCShareSessionStore.swift, + NCShareAccountMenu.swift, + NCShareUploadConflictView.swift, + NCShareUploadButton.swift, + NCShareFilenameView.swift, + NCShareView.swift, + ); GCC_SYMBOLS_PRIVATE_EXTERN = YES; INFOPLIST_FILE = "$(SRCROOT)/Brand/iOSClient.plist"; OTHER_LDFLAGS = ""; @@ -6679,7 +6694,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 35.2.2; + MARKETING_VERSION = 35.3.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; @@ -6745,7 +6760,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 35.2.2; + MARKETING_VERSION = 35.3.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; @@ -7119,8 +7134,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - kind = exactVersion; - version = 7.6.4; + branch = main; + kind = branch; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { @@ -7224,11 +7239,6 @@ package = F33EE6DF2BF4BDA500CA1A51 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */; productName = NIOSSL; }; - F33EE6E62BF4C02600CA1A51 /* NIOSSL */ = { - isa = XCSwiftPackageProductDependency; - package = F33EE6DF2BF4BDA500CA1A51 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */; - productName = NIOSSL; - }; F33EE6EC2BF4C03800CA1A51 /* NIOSSL */ = { isa = XCSwiftPackageProductDependency; package = F33EE6DF2BF4BDA500CA1A51 /* XCRemoteSwiftPackageReference "swift-nio-ssl" */; @@ -7334,11 +7344,6 @@ package = F70557B52ED44E2700135623 /* XCRemoteSwiftPackageReference "LucidBanner" */; productName = LucidBanner; }; - F70821D729E59E6D001CA2D7 /* TagListView */ = { - isa = XCSwiftPackageProductDependency; - package = F7A1050C29E587AF00FFD92B /* XCRemoteSwiftPackageReference "TagListView" */; - productName = TagListView; - }; F70B86742642CE3B00ED5349 /* FirebaseCrashlytics */ = { isa = XCSwiftPackageProductDependency; package = F70B86732642CE3B00ED5349 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; @@ -7419,11 +7424,6 @@ package = F783034028B511D200B84583 /* XCRemoteSwiftPackageReference "NextcloudKit" */; productName = NextcloudKit; }; - F72D7EB6263B1207000B3DFC /* MarkdownKit */ = { - isa = XCSwiftPackageProductDependency; - package = F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */; - productName = MarkdownKit; - }; F72DA9B325F53E4E00B87DB1 /* SwiftRichString */ = { isa = XCSwiftPackageProductDependency; package = F72DA9B225F53E4E00B87DB1 /* XCRemoteSwiftPackageReference "SwiftRichString" */; diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 89e544897d..4e862cb430 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -204,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "revision" : "49eee789ab676d6aba0020ccbe0d850f62e1d449", - "version" : "7.6.4" + "branch" : "main", + "revision" : "62d52ad3ac203eebb80c4999ae304d4fc6da6f3b" } }, { diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme index 2d901b5ea6..3b6b034618 100755 --- a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme +++ b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> @@ -65,13 +65,13 @@ + RemotePath = "/var/containers/Bundle/Application/690609F7-C7AD-4102-9416-5C89CC5CD598/Nextcloud.app"> , () +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: +// SPDX-License-Identifier: GPL-3.0-or-later ``` Please read the [Code of Conduct](https://nextcloud.com/code-of-conduct/). This document offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other. @@ -38,7 +40,9 @@ branch. Maybe start working on [starter issues](https://github.com/nextcloud/ios Easy starting points are also reviewing [pull requests](https://github.com/nextcloud/ios/pulls) -### Xcode 26.1 Project Setup +### Xcode 27.2 Project Setup + +Open `Nextcloud.xcodeproj`; package dependencies are managed by Swift Package Manager. The CI toolchain and simulator destination are defined in [.github/workflows/xcode.yml](.github/workflows/xcode.yml) (currently Xcode 27.1 and iPhone 17 with iOS 27.0). #### Dependencies @@ -83,9 +87,8 @@ Mock everything which is not needed. ### Integration Tests -In `TestConstants.swift` you must specify your credentials for the server you want to test against. -For locally running tests and in the GitHub actions we use [this shallow CI server image](https://github.com/nextcloud/docker-ci/pkgs/container/continuous-integration-shallow-server). -Due to technical constraints the GitHub actions rely on a bare metal installation of Nextcloud on the runner in difference to local tests which can leverage much cleaner and reliable Docker containers. +Configure the server URL and credentials in [Tests/TestConstants.swift](Tests/TestConstants.swift). The defaults are `http://localhost:8080` and `admin` / `admin`, matching CI. +Local tests use the [shallow CI server image](https://github.com/nextcloud/docker-ci/pkgs/container/continuous-integration-shallow-server) through the Docker script below. GitHub Actions instead installs Nextcloud directly on the runner and serves it on port 8080 using PHP. Both currently use the `stable30` server branch. Currently, you still need to launch the server manually once before running any tests locally. This project comes with a convenience script to do so, see or run [Tests/Server.sh](Tests/Server.sh). diff --git a/Share/MainInterface.storyboard b/Share/MainInterface.storyboard deleted file mode 100644 index 221ff91c0c..0000000000 --- a/Share/MainInterface.storyboard +++ /dev/null @@ -1,267 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift new file mode 100644 index 0000000000..0096366a7f --- /dev/null +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit +import UIKit + +extension NCShareViewModel { + func loadAccounts() { + guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, + !isLoadingAccounts else { return } + accountsTask = Task { @MainActor [self] in + defer { accountsTask = nil } + let selected = sessionStore.getSession().account + let storedAccounts = await NCManageDatabase.shared.getAllAccountOrderAliasAsync() + guard !Task.isCancelled, !isClosing, isAuthenticated, + sessionStore.getSession().account == selected else { return } + guard !storedAccounts.isEmpty else { + clearAccountView() + showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } + return + } + accounts = storedAccounts.map { account in + let name = account.displayName.isEmpty ? account.user : account.displayName + return NCShareAccountItem( + id: account.account, + name: account.alias.isEmpty ? name : name + " - " + account.alias, + user: account.user, + serverUrl: account.urlBase, + isSelected: account.account == selected, + avatar: utility.loadUserImage(for: account.user, displayName: account.displayName, urlBase: account.urlBase)) + }.sorted { $0.isSelected && !$1.isSelected } + } + } + + func selectAccount(_ account: String) { + guard isAuthenticated, !isClosing, !isPreparingUpload, !isSwitchingAccount, + accounts.contains(where: { $0.id == account }) else { return } + guard account != sessionStore.getSession().account else { return } + changeAccount(account) + } + + /// Uses a request-local account without changing the main app selection. + /// Shows cached folders first, then resolves capabilities and refreshes the root. + func changeAccount(_ account: String) { + guard !isClosing, !isPreparingUpload else { return } + accountChangeTask?.cancel() + // Cancellation alone cannot prevent a late callback from publishing old data. + // Check this generation again after awaits before updating the interface. + let changeID = UUID() + accountChangeID = changeID + isSwitchingAccount = true + clearAccountView(preservingContent: true) + accountChangeTask = Task { @MainActor [self] in + defer { if accountChangeID == changeID { isSwitchingAccount = false } } + let selectedSession = await sessionStore.setSessionAccount(account) + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } + guard let session = selectedSession, + let tblAccount = sessionStore.account, tblAccount.account == account else { + showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } + return + } + NextcloudKit.shared.setup(groupIdentifier: NCBrandOptions.shared.capabilitiesGroup, delegate: NCNetworking.shared) + NextcloudKit.shared.appendSession(account: tblAccount.account, + urlBase: tblAccount.urlBase, + user: tblAccount.user, + userId: tblAccount.userId, + password: NCPreferences().getPassword(account: tblAccount.account), + userAgent: userAgent, + httpMaximumConnectionsPerHost: NCBrandOptions.shared.httpMaximumConnectionsPerHost, + httpMaximumConnectionsPerHostInDownload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInDownload, + httpMaximumConnectionsPerHostInUpload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInUpload, + groupIdentifier: NCBrandOptions.shared.capabilitiesGroup) + autoUploadFileName = NCManageDatabase.shared.getAccountAutoUploadFileName(account: account) + autoUploadDirectory = NCManageDatabase.shared.getAccountAutoUploadDirectory( + account: account, urlBase: session.urlBase, userId: session.userId) + // Publish the local browser before capabilities can trigger a network request. + await initializeFolderBrowser(refreshFromServer: false) + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } + isSwitchingAccount = false + loadAccounts() + await loadServerCapabilities(account: account, changeID: changeID) + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } + if serverCapabilities != nil, let root = rootFolder { + requestFolderChildren(root, refresh: true) + } + } + } + + func retryCapabilities() { + guard !isOffline, isAuthenticated, !isClosing, !isLoadingCapabilities, !isSwitchingAccount, !isPreparingUpload else { return } + let account = sessionStore.getSession().account + guard !account.isEmpty else { return } + let changeID = accountChangeID + capabilitiesTask = Task { @MainActor in + await loadServerCapabilities(account: account, changeID: changeID) + guard isCurrentCapabilitiesRequest(account: account, changeID: changeID), serverCapabilities != nil else { return } + if let rootFolder { + requestFolderChildren(rootFolder, refresh: true) + } else { + await initializeFolderBrowser() + } + } + } + + /// Resolves one capabilities snapshot for this account: database first, network + /// only when absent. All Share actions use the resulting serverCapabilities. + private func loadServerCapabilities(account: String, changeID: UUID) async { + isLoadingCapabilities = true + serverCapabilities = nil + capabilitiesErrorMessage = "" + defer { if accountChangeID == changeID { isLoadingCapabilities = false } } + // Prefer the actual cached response, rather than default capabilities. + var capabilities = await NCManageDatabase.shared.getCapabilities(account: account) + guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } + if capabilities == nil { + guard !isOffline else { return } + let result = await NextcloudKit.shared.getCapabilitiesAsync(account: account) { [weak self] task in + Task { @MainActor [weak self] in + guard let self, !self.isClosing, self.accountChangeID == changeID else { + task.cancel() + return + } + self.capabilitiesRequest = task + } + } + guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } + guard result.error == .success, let loaded = result.capabilities else { + capabilitiesErrorMessage = NSLocalizedString("_share_capabilities_unavailable_", comment: "") + return + } + if let data = result.responseData?.data { + await NCManageDatabase.shared.setDataCapabilities(data: data, account: account) + } + guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } + capabilities = loaded + } + guard let capabilities else { return } + // Keep legacy networking services and NextcloudKit in sync with this request. + await NKCapabilities.shared.setCapabilities(for: account, capabilities: capabilities) + guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } + NCNetworking.shared.capabilities[account] = capabilities + serverCapabilities = capabilities + NCBrandColor.shared.settingThemingColor(account: account, capabilities: capabilities) + } + + private func isCurrentCapabilitiesRequest(account: String, changeID: UUID) -> Bool { + !Task.isCancelled && !isClosing && accountChangeID == changeID && sessionStore.getSession().account == account + } +} diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift new file mode 100644 index 0000000000..bd826dd9a3 --- /dev/null +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +extension NCShareViewModel { + /// Flattens expanded branches into rows; collapsed children stay cached. + var visibleFolderRows: [NCShareFolderRow] { + var rows: [NCShareFolderRow] = [] + var visited: Set = [] + func append(_ folder: NCShareFolderItem, depth: Int) { + guard visited.insert(folder.serverUrl).inserted else { return } + rows.append(NCShareFolderRow(folder: folder, depth: depth)) + if expandedFolderURLs.contains(folder.serverUrl) { + for child in folderChildren[folder.serverUrl] ?? [] { append(child, depth: depth + 1) } + } + } + if let rootFolder { append(rootFolder, depth: 0) } + return rows + } + + func resetFolderBrowser(preservingContent: Bool = false) { + folderBrowserID = UUID() + folderSelectionTask?.cancel() + for task in folderTasks.values { task.cancel() } + for request in folderRequests.values { request.cancel() } + folderTasks.removeAll() + folderRequests.removeAll() + // Keep the previous snapshot visible but disabled while the account changes. + if !preservingContent { + rootFolder = nil + recentFolders = [] + selectedFolder = nil + isRecentFolderSelected = false + folderChildren = [:] + expandedFolderURLs = [] + } + folderScrollTarget = nil + loadedFolderURLs = [] + folderLoadErrors = [:] + isLoading = false + } + + func initializeFolderBrowser(refreshFromServer: Bool = true) async { + let session = sessionStore.getSession() + let browserID = folderBrowserID + let home = utilityFileSystem.getHomeServer(session: session) + let root = NCShareFolderItem( + ocId: "", name: NSLocalizedString("_share_home_folder_", comment: ""), serverUrl: home, + path: "/", image: NCImageCache.shared.getFolder(account: session.account), isEncrypted: false) + let recent = recentFoldersStore.folders(account: session.account).filter { + $0.serverUrl == home || $0.serverUrl.hasPrefix(home + "/") + }.map { item in + NCShareFolderItem( + ocId: item.ocId, name: item.name, serverUrl: item.serverUrl, + path: folderPath(item.serverUrl, home: home), + image: item.isEncrypted ? NCImageCache.shared.getFolderEncrypted(account: session.account) : NCImageCache.shared.getFolder(account: session.account), + isEncrypted: item.isEncrypted) + } + let cached = await cachedFolderChildren(at: home, session: session) + guard isCurrentBrowser(browserID, account: session.account) else { return } + // Publish the replacement snapshot together, without an empty intermediate list. + rootFolder = root + selectedFolder = root + isRecentFolderSelected = false + expandedFolderURLs = [home] + recentFolders = recent + folderChildren = [home: cached] + // Make cached destinations usable without waiting for the server refresh. + if refreshFromServer { requestFolderChildren(root) } + } + + func toggleFolder(_ folder: NCShareFolderItem) { + guard canUseFolderBrowser, canAccessFolder(folder) else { return } + if expandedFolderURLs.contains(folder.serverUrl) { + expandedFolderURLs.remove(folder.serverUrl) + } else { + expandedFolderURLs.insert(folder.serverUrl) + if !loadedFolderURLs.contains(folder.serverUrl) { requestFolderChildren(folder) } + } + } + + func retryFolder(_ folder: NCShareFolderItem) { + guard canUseFolderBrowser, canAccessFolder(folder) else { return } + expandedFolderURLs.insert(folder.serverUrl) + requestFolderChildren(folder, refresh: true) + } + + /// Keeps at most one listing request per folder, while allowing independent + /// branches to load concurrently. Browser reset cancels all registered requests. + func requestFolderChildren(_ folder: NCShareFolderItem, refresh: Bool = false) { + guard !isOffline, !isClosing, folderTasks[folder.serverUrl] == nil, + refresh || !loadedFolderURLs.contains(folder.serverUrl) else { return } + let browserID = folderBrowserID + // Mark synchronously, before scheduling, to prevent duplicate requests. + folderTasks[folder.serverUrl] = Task { @MainActor in + await loadFolderChildren(folder, browserID: browserID) + } + } + + /// Validates the destination on the server, then selects and expands it. + /// fromRecents controls which of the two occurrences displays the checkmark. + func selectFolder(_ folder: NCShareFolderItem, fromRecents: Bool = false) { + guard canUseFolderBrowser, let capabilities = serverCapabilities, canAccessFolder(folder) else { return } + let session = sessionStore.getSession() + let browserID = folderBrowserID + isLoading = true + folderSelectionTask = Task { @MainActor in + defer { + if folderBrowserID == browserID { isLoading = false } + } + // Recent destinations may have been deleted or moved since last use. + let result = await NCNetworking.shared.readFileAsync(serverUrlFileName: folder.serverUrl, account: session.account) + guard isCurrentBrowser(browserID, account: session.account) else { return } + guard result.error == .success, let metadata = result.metadata, metadata.directory else { + if result.error.errorCode == NCGlobal.shared.errorResourceNotFound || result.error == .success { + recentFoldersStore.remove(serverUrl: folder.serverUrl, account: session.account) + recentFolders.removeAll { $0.serverUrl == folder.serverUrl } + if fromRecents { return } + } + showAlert(description: result.error == .success ? "_share_folder_unavailable_" : result.error.errorDescription) + return + } + let selected = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) + guard canAccessFolder(selected) else { return } + guard isCurrentBrowser(browserID, account: session.account), isAuthenticated else { return } + let home = utilityFileSystem.getHomeServer(session: session) + if folder.serverUrl != home, + let error = FileNameValidator.checkFileName(metadata.fileNameView, account: session.account, capabilities: capabilities) { + showAlert(description: error.errorDescription) + return + } + selectedFolder = selected + isRecentFolderSelected = fromRecents + expandedFolderURLs.insert(selected.serverUrl) + requestFolderChildren(selected) + } + } + + var canUseFolderBrowser: Bool { + isAuthenticated && !isClosing && isAccountReady && !isLoading && !isPreparingUpload && !isLoadingAccounts + } + + private func canAccessFolder(_ folder: NCShareFolderItem) -> Bool { + let session = sessionStore.getSession() + let home = utilityFileSystem.getHomeServer(session: session) + guard folder.serverUrl == home || folder.serverUrl.hasPrefix(home + "/") else { return false } + if folder.isEncrypted && !NCPreferences().isEndToEndEnabled(account: session.account) { + showAlert(description: "_e2e_goto_settings_for_enable_") + return false + } + return true + } + + func isCurrentBrowser(_ browserID: UUID, account: String) -> Bool { + !Task.isCancelled && !isClosing && folderBrowserID == browserID && sessionStore.getSession().account == account + } + + private func loadFolderChildren(_ folder: NCShareFolderItem, browserID: UUID) async { + let session = sessionStore.getSession() + guard isCurrentBrowser(browserID, account: session.account) else { return } + folderLoadErrors[folder.serverUrl] = nil + defer { + if folderBrowserID == browserID { + folderRequests[folder.serverUrl] = nil + folderTasks[folder.serverUrl] = nil + } + } + let result = await NextcloudKit.shared.readFileOrFolderAsync( + serverUrlFileName: folder.serverUrl, depth: "1", + showHiddenFiles: NCPreferences().getShowHiddenFiles(account: session.account), account: session.account + ) { [weak self] task in + Task { @MainActor [weak self] in + guard let self, self.folderBrowserID == browserID, !self.isClosing else { + task.cancel() + return + } + self.folderRequests[folder.serverUrl] = task + } + } + + guard isCurrentBrowser(browserID, account: session.account) else { return } + guard result.error == .success else { + folderLoadErrors[folder.serverUrl] = result.error.userFriendlyMessage() + return + } + guard let files = result.files else { + folderLoadErrors[folder.serverUrl] = NSLocalizedString("_share_folder_unavailable_", comment: "") + return + } + // The destination browser needs only directories. Do not convert file + // types or replace the app's complete metadata listing in the database. + let directories = files.filter(\.directory) + let listing = await NCManageDatabaseCreateMetadata().convertFilesToMetadatasAsync( + directories, serverUrlMetadataFolder: folder.serverUrl) + guard isCurrentBrowser(browserID, account: session.account) else { return } + guard !listing.metadataFolder.ocId.isEmpty else { + folderLoadErrors[folder.serverUrl] = NSLocalizedString("_share_folder_unavailable_", comment: "") + return + } + let layout = NCManageDatabase.shared.getLayoutForView(account: session.account, key: NCGlobal.shared.layoutViewShareExtension, serverUrl: folder.serverUrl) + let sorted = await NCManageDatabase.shared.sortedMetadata(layoutForView: layout, account: session.account, metadatas: listing.metadatas) + guard isCurrentBrowser(browserID, account: session.account) else { return } + folderChildren[folder.serverUrl] = folderSnapshots(sorted, parent: listing.metadataFolder, session: session) + loadedFolderURLs.insert(folder.serverUrl) + let metadata = listing.metadataFolder + let updated = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) + if rootFolder?.serverUrl == folder.serverUrl { rootFolder = updated } + if selectedFolder?.serverUrl == folder.serverUrl { + selectedFolder = updated + } + } + + private func cachedFolderChildren(at url: String, session: NCSession.Session) async -> [NCShareFolderItem] { + let layout = NCManageDatabase.shared.getLayoutForView(account: session.account, key: NCGlobal.shared.layoutViewShareExtension, serverUrl: url) + let hidden = NCPreferences().getShowHiddenFiles(account: session.account) + let predicate = hidden + ? NSPredicate(format: "account == %@ AND serverUrl == %@ AND directory == true AND fileName != %@", session.account, url, NextcloudKit.shared.nkCommonInstance.rootFileName) + : NSPredicate(format: "account == %@ AND serverUrl == %@ AND directory == true AND fileName != %@ AND NOT fileName BEGINSWITH[c] %@", session.account, url, NextcloudKit.shared.nkCommonInstance.rootFileName, ".") + let metadatas = await NCManageDatabase.shared.getMetadatasAsync(predicate: predicate, withLayout: layout, withAccount: session.account) + return folderSnapshots(metadatas, parent: nil, session: session) + } + + private func folderSnapshots(_ metadatas: [tableMetadata], parent: tableMetadata?, session: NCSession.Session) -> [NCShareFolderItem] { + let ordered = metadatas.filter(\.favorite) + metadatas.filter { !$0.favorite } + return ordered.map { metadata in + let childUrl = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) + return folderSnapshot(metadata, url: childUrl, parent: parent, session: session) + } + } + + private func folderSnapshot(_ metadata: tableMetadata, url: String, parent: tableMetadata?, session: NCSession.Session) -> NCShareFolderItem { + let home = utilityFileSystem.getHomeServer(session: session) + let image: UIImage + let shared = parent.map { metadata.permissions.contains(NCMetadataPermissions.permissionShared) && !$0.permissions.contains(NCMetadataPermissions.permissionShared) } ?? false + let mounted = parent.map { metadata.permissions.contains(NCMetadataPermissions.permissionMounted) && !$0.permissions.contains(NCMetadataPermissions.permissionMounted) } ?? false + if metadata.e2eEncrypted { + image = NCImageCache.shared.getFolderEncrypted(account: session.account) + } else if shared { + image = NCImageCache.shared.getFolderSharedWithMe(account: session.account) + } else if !metadata.shareType.isEmpty { + image = metadata.shareType.contains(3) ? NCImageCache.shared.getFolderPublic(account: session.account) : NCImageCache.shared.getFolderSharedWithMe(account: session.account) + } else if metadata.mountType == "group" { + image = NCImageCache.shared.getFolderGroup(account: session.account) + } else if mounted { + image = NCImageCache.shared.getFolderExternal(account: session.account) + } else if metadata.fileName == autoUploadFileName && metadata.serverUrl == autoUploadDirectory { + image = NCImageCache.shared.getFolderAutomaticUpload(account: session.account) + } else { + image = NCImageCache.shared.getFolder(account: session.account) + } + return NCShareFolderItem( + ocId: metadata.ocId, name: url == home ? NSLocalizedString("_share_home_folder_", comment: "") : metadata.fileNameView, + serverUrl: url, path: folderPath(url, home: home), image: image, + isEncrypted: metadata.e2eEncrypted, isFavorite: metadata.favorite) + } + + private func folderPath(_ url: String, home: String) -> String { + let path = url.hasPrefix(home) ? String(url.dropFirst(home.count)) : url + return path.isEmpty ? "/" : (path.removingPercentEncoding ?? path) + } +} diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift new file mode 100644 index 0000000000..77d4528467 --- /dev/null +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +// The unit-test target compiles only the pure name planner below. +#if EXTENSION_SHARE || SHARE_PREVIEWS +import UIKit +import NextcloudKit +import LucidBanner + +// MARK: - Upload +extension NCShareViewModel { + var canUpload: Bool { canUseUploadDestination && !fileNames.isEmpty } + + /// Upload flow: inspect the destination, resolve name conflicts if needed, + /// stage local copies, then transfer. Keep editing disabled throughout this flow. + func upload() { + guard canUpload, let capabilities = serverCapabilities else { return } + runUpload { [self] in + let plan = try await prepareUploadPlan(capabilities: capabilities) + try Task.checkCancellation() + guard !isClosing, isAuthenticated else { return } + if plan.requiresConfirmation { + pendingUploadConfirmation = (plan, nil) + isShowingUploadConfirmation = true + } else { + try await stageAndUpload(plan: plan, replacing: false, capabilities: capabilities) + } + } + } + + // Store the choice and dismiss the sheet first. Starting the transfer here + // would overlap sheet dismissal with the upload banner presentation. + func resolveUploadConfirmation(_ choice: NCShareUploadChoice?) { + pendingUploadConfirmation?.choice = choice + isShowingUploadConfirmation = false + } + + func uploadConfirmationDismissed() { + guard let (plan, choice) = pendingUploadConfirmation else { return } + pendingUploadConfirmation = nil + guard let choice, let capabilities = serverCapabilities, isAuthenticated, !isClosing else { + isPreparingUpload = false + if !isAuthenticated && !isClosing { + validateAccountAndRequestPasscode() + } + return + } + runUpload { [self] in + try await stageAndUpload(plan: plan, replacing: choice == .replace, capabilities: capabilities) + } + } + + private func runUpload(_ operation: @escaping @MainActor () async throws -> Void) { + isPreparingUpload = true + uploadTask = Task { @MainActor in + defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } + do { + try await operation() + } catch is CancellationError { + // No upload after cancellation or a destination change. + } catch { + if !Task.isCancelled, !isClosing { showAlert(description: error.localizedDescription) } + } + } + } + + /// Builds a plan from a fresh server listing without renaming the input files. + /// Multiple files receive unique copy names; a single file may also be replaced + /// when the existing resource is a file with a usable ETag. + private func prepareUploadPlan(capabilities: NKCapabilities.Capabilities) async throws -> NCShareUploadConfirmation { + let session = sessionStore.getSession() + guard let destination = selectedFolder?.serverUrl else { throw CancellationError() } + let sourceNames = fileNames + guard !sourceNames.isEmpty else { throw NCShareRequestError.noFiles } + let renamer = FileAutoRenamer(capabilities: capabilities) + let requestedNames = try sourceNames.map { name in + let candidate = renamer.rename(filename: name).trimmingCharacters(in: .whitespacesAndNewlines) + if let error = FileNameValidator.checkFileName(candidate, account: session.account, capabilities: capabilities) { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: name + ": " + error.errorDescription) + } + return candidate + } + + // Read the server, not the browser cache. Hidden files also reserve names. + let result = await NextcloudKit.shared.readFileOrFolderAsync( + serverUrlFileName: destination, depth: "1", showHiddenFiles: true, account: session.account) + try Task.checkCancellation() + guard result.error == .success else { throw result.error } + guard let files = result.files else { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) + } + guard !isClosing, sessionStore.getSession().account == session.account, selectedFolder?.serverUrl == destination else { throw CancellationError() } + let converter = NCManageDatabaseCreateMetadata() + var listing = await converter.convertFilesToMetadatasAsync(files, serverUrlMetadataFolder: destination) + guard !listing.metadataFolder.ocId.isEmpty else { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) + } + // Encrypted server names cannot be compared with the user-visible names. + // Decode the mapping and verify write access before allocating upload names. + if listing.metadataFolder.e2eEncrypted { + let networking = NCNetworkingE2EE() + await NCNetworkingE2EE.beginNewServerKeyValidationCycle() + let keyError = await networking.validateCurrentServerKey(account: session.account) + guard keyError == .success else { throw keyError } + let encrypted = await networking.getMetadata(fileId: listing.metadataFolder.ocId, e2eToken: nil, account: session.account) + if encrypted.error == .success, let metadata = encrypted.e2eMetadata { + let decoded = await NCEndToEndMetadata().decodeMetadata(metadata, signature: encrypted.signature, serverUrl: destination, session: session) + guard decoded.error == .success else { throw decoded.error } + guard decoded.access.writeAccessError == .success else { throw decoded.access.writeAccessError } + listing = await converter.convertFilesToMetadatasAsync(files, serverUrlMetadataFolder: destination) + } else if encrypted.error == .success { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) + } else if encrypted.error.errorCode != NCGlobal.shared.errorResourceNotFound || !listing.metadatas.isEmpty { + throw encrypted.error + } + guard !listing.metadatas.contains(where: { $0.fileNameView == NSLocalizedString("_e2e_file_encrypted_", comment: "") }) else { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) + } + } + try Task.checkCancellation() + // Retain the current encrypted-name mapping for the shared E2EE service. + await NCManageDatabase.shared.createDirectory(metadata: listing.metadataFolder) + await NCManageDatabase.shared.updateMetadatasFilesAsync(listing.metadatas, serverUrl: destination, account: session.account) + let names = Set(listing.metadatas.map(\.fileNameView)) + let allocated = NCShareUploadNamePlanner.fileNames(for: requestedNames, existingNames: names) + let items = zip(sourceNames, allocated).map { NCShareUploadItem(sourceName: $0.0, fileName: $0.1) } + let existing = requestedNames.count == 1 ? listing.metadatas.first(where: { $0.fileNameView == requestedNames[0] }) : nil + let canReplace = existing.map { !$0.directory && !$0.etag.isEmpty && !$0.etag.hasPrefix("W/") } ?? false + return NCShareUploadConfirmation( + account: session.account, serverUrl: destination, items: items, + replacementFileName: canReplace ? existing?.fileNameView : nil, + replacementETag: canReplace ? existing?.etag : nil, + requiresConfirmation: allocated != requestedNames) + } + + /// Copies inputs into the storage expected by the upload services. If staging + /// fails, remove those copies; retain the originals so the user can retry. + private func stageAndUpload(plan: NCShareUploadConfirmation, replacing: Bool, capabilities: NKCapabilities.Capabilities) async throws { + try Task.checkCancellation() + guard !isOffline else { throw URLError(.notConnectedToInternet) } + let session = sessionStore.getSession() + guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == selectedFolder?.serverUrl else { throw CancellationError() } + var uploadMetadata: [tableMetadata] = [] + let replacementETag: String? + if replacing, let etag = plan.replacementETag { + replacementETag = etag.hasPrefix("\"") || etag.hasPrefix("W/") ? etag : "\"" + etag + "\"" + } else { + replacementETag = nil + } + var stagedDirectories: [String] = [] + var staged = false + defer { + if !staged { + for directory in stagedDirectories { try? FileManager.default.removeItem(atPath: directory) } + } + } + for item in plan.items { + try Task.checkCancellation() + let fileName = replacing ? (plan.replacementFileName ?? item.fileName) : item.fileName + if let error = FileNameValidator.checkFileName(fileName, account: session.account, capabilities: capabilities) { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: fileName + ": " + error.errorDescription) + } + let ocId = UUID().uuidString + let directory = utilityFileSystem.getDirectoryProviderStorageOcId(ocId, userId: session.userId, urlBase: session.urlBase) + stagedDirectories.append(directory) + let path = URL(fileURLWithPath: directory).appendingPathComponent(fileName).path + try FileManager.default.copyItem(at: inputFileURL(named: item.sourceName), to: URL(fileURLWithPath: path)) + let metadata = await NCManageDatabaseCreateMetadata().createMetadataAsync( + fileName: fileName, ocId: ocId, serverUrl: plan.serverUrl, session: session, sceneIdentifier: nil) + metadata.nativeFormat = true + metadata.session = NCNetworking.shared.sessionUpload + metadata.sessionSelector = NCGlobal.shared.selectorUploadFileShareExtension + metadata.size = utilityFileSystem.getFileSize(filePath: path) + metadata.status = NCGlobal.shared.metadataStatusWaitUpload + metadata.sessionDate = Date() + uploadMetadata.append(metadata) + } + try Task.checkCancellation() + guard !isClosing, isAuthenticated, sessionStore.getSession().account == plan.account, selectedFolder?.serverUrl == plan.serverUrl else { throw CancellationError() } + staged = true + await uploadAndExit(uploadMetadata, replacementETag: replacementETag) + } + + private func uploadAndExit(_ uploadMetadata: [tableMetadata], replacementETag: String?) async { + // A Share extension process can be reused by the system. Treat each + // explicit share operation as a new server-key validation cycle. + await NCNetworkingE2EE.beginNewServerKeyValidationCycle() + + var error: NKError? + var errorContext: NCErrorContext = .generic + guard !Task.isCancelled, !isClosing, let window = presenter?.view.window else { + return + } + let payload = LucidBannerPayload(stage: .button, + vPosition: .center, + blocksTouches: true) + let (uploadBanner, token) = showUploadBanner(windowScene: window.windowScene, + payload: payload, + allowMinimizeOnTap: false, + onButtonTap: { self.cancel() }) + banner = uploadBanner + + for (index, metadata) in uploadMetadata.enumerated() { + guard !Task.isCancelled, !isClosing else { return } + // BANNER + let payloadUpdate = LucidBannerPayload.Update( + title: NSLocalizedString("_upload_file_", comment: "") + " \(index + 1) " + NSLocalizedString("_of_", comment: "") + " \(uploadMetadata.count)", + systemImage: "arrowshape.up.circle", + imageAnimation: .breathe, + progress: 0) + banner?.update(payload: payloadUpdate) + + error = await self.upload(metadata: metadata, replacementETag: replacementETag, token: token) + guard !Task.isCancelled, !isClosing else { return } + if error != .success { + errorContext = .upload(fileName: NCShareFilenameView.displayName(metadata.fileNameView)) + break + } + } + + if error == .success { + if let uploaded = uploadMetadata.first, let folder = selectedFolder, + uploaded.serverUrl == folder.serverUrl { + recentFoldersStore.record(.init( + serverUrl: folder.serverUrl, name: folder.name, ocId: folder.ocId, isEncrypted: folder.isEncrypted), + account: uploaded.account) + } + banner?.update(payload: LucidBannerPayload.Update(stage: .success, horizontalLayout: .centered(width: 100)), for: token) + } else { + let payload = LucidBannerPayload.Update( + title: errorContext.failureTitle ?? NSLocalizedString("_error_", comment: ""), + subtitle: error?.userFriendlyMessage(for: errorContext) ?? "", stage: .error) + banner?.update(payload: payload, for: token) + } + + guard !Task.isCancelled, !isClosing else { return } + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return + } + guard !isClosing else { return } + banner?.dismiss() + cancel() + } + + private func upload(metadata: tableMetadata, replacementETag: String?, token: Int?) async -> NKError { + let session = sessionStore.getSession() + + let results = await NKTypeIdentifiers.shared.getInternalType(fileName: metadata.fileNameView, mimeType: metadata.contentType, directory: false, account: session.account) + guard !Task.isCancelled, !isClosing else { + return NKError(errorCode: NSURLErrorCancelled, errorDescription: "Transfer was cancelled.") + } + metadata.contentType = results.mimeType + metadata.iconName = results.iconName + metadata.classFile = results.classFile + metadata.typeIdentifier = results.typeIdentifier + metadata.serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) + + let chunkSize = NCGlobal.shared.chunkSize + metadata.chunk = metadata.size > chunkSize ? chunkSize : 0 + // E2EE + metadata.e2eEncrypted = metadata.isDirectoryE2EE + + if metadata.isDirectoryE2EE { + return await NCNetworkingE2EEUpload().upload(metadata: metadata, + session: session, + controller: presenter, + banner: banner, + stageBanner: nil, + tokenBanner: token) + } else if metadata.chunk > 0 { + banner?.update(payload: LucidBannerPayload.Update(systemImage: "gearshape.arrow.triangle.2.circlepath", + imageAnimation: .rotate), + for: token) + // Await directly so cancellation belongs to the main upload task. + // Final MOVE must not overwrite a file created after conflict checking, + // unless the user chose replacement and its ETag still matches. + let results = await NCNetworking.shared.uploadChunkFile( + metadata: metadata, + overwrite: replacementETag != nil, + ifMatch: replacementETag) { total, counter in + Task {@MainActor in + self.banner?.update(payload: LucidBannerPayload.Update(progress: Double(counter) / Double(total)), for: token) + } + } uploadStart: { _ in + Task {@MainActor in + let payload = LucidBannerPayload.Update( + systemImage: "arrowshape.up.circle", + imageAnimation: .breathe + ) + self.banner?.update(payload: payload, for: token) + } + } uploadProgressHandler: { _, _, progress in + Task {@MainActor in + self.banner?.update(payload: LucidBannerPayload.Update(progress: progress), for: token) + } + } assembling: { + Task {@MainActor in + let payload = LucidBannerPayload.Update( + systemImage: "gearshape.arrow.triangle.2.circlepath", + imageAnimation: .rotate + ) + self.banner?.update(payload: payload, for: token) + } + } + return results.error + } else { + let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, + fileName: metadata.fileName, + userId: metadata.userId, + urlBase: metadata.urlBase) + + // The server enforces the final decision: a free name must still be + // free, and a replacement must still match the ETag we inspected. + let results = await NCNetworking.shared.uploadFile(account: metadata.account, + fileNameLocalPath: fileNameLocalPath, + serverUrlFileName: metadata.serverUrlFileName, + creationDate: metadata.creationDate as Date, + dateModificationFile: metadata.date as Date, + customHeaders: replacementETag.map { ["If-Match": $0] } ?? ["If-None-Match": "*"], + progressHandler: { _, _, fractionCompleted in + Task {@MainActor in + self.banner?.update(payload: LucidBannerPayload.Update(progress: fractionCompleted), + for: token) + } + }) + return results.error + } + + } +} +#endif + +/// Reserves the entire batch before allocating copies, so an earlier conflict +/// cannot consume the unchanged name of a later attachment. +struct NCShareUploadNamePlanner { + static func fileNames(for names: [String], existingNames: Set) -> [String] { + var occupied = existingNames + let reserved = Set(names) + return names.map { name in + if !occupied.contains(name) { + occupied.insert(name) + return name + } + let filename = name as NSString + let ext = filename.pathExtension + var stem = ext.isEmpty ? name : filename.deletingPathExtension + var number = 1 + if let range = stem.range(of: #" \([0-9]+\)$"#, options: .regularExpression), + let value = Int(stem[range].dropFirst(2).dropLast()), value > 0, value < Int.max / 2 { + number = value + 1 + stem = String(stem[.. = [] + @Published var loadedFolderURLs: Set = [] + @Published var folderLoadErrors: [String: String] = [:] + @Published var folderTasks: [String: Task] = [:] + var folderRequests: [String: URLSessionTask] = [:] + var folderSelectionTask: Task? + // Invalidates asynchronous folder results when the account/browser is reset. + var folderBrowserID = UUID() + lazy var recentFoldersStore = NCShareRecentFoldersStore(defaults: UserDefaults(suiteName: NCBrandOptions.shared.capabilitiesGroup) ?? .standard) + @Published var fileNames: [String] = [] + @Published var fileThumbnails: [String: UIImage] = [:] + private var thumbnailRequests: [String: QLThumbnailGenerator.Request] = [:] + private var failedThumbnails: Set = [] + @Published var isLoading = false + @Published private(set) var isOffline = false + private var connectionMonitor: NWPathMonitor? + @Published var isPreparingUpload = false + @Published var isShowingUploadConfirmation = false + // Keep the plan and choice until SwiftUI finishes dismissing the sheet. + var pendingUploadConfirmation: (plan: NCShareUploadConfirmation, choice: NCShareUploadChoice?)? + var uploadTask: Task? + private(set) var isClosing = false + @Published var isPreparingInput = false + @Published var inputErrorMessage = "" + @Published var alertMessage = "" + private var alertDismissAction: (() -> Void)? + @Published var accounts: [NCShareAccountItem] = [] + @Published var isSwitchingAccount = false + @Published var serverCapabilities: NKCapabilities.Capabilities? + @Published var isLoadingCapabilities = false + @Published var capabilitiesErrorMessage = "" + var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount && !isOffline } + + var canUseUploadDestination: Bool { + guard let selectedFolder else { return false } + return canUseFolderBrowser && !isLoadingCapabilities && folderTasks.isEmpty && + folderLoadErrors[selectedFolder.serverUrl] == nil && alertMessage.isEmpty && + !isPreparingInput && inputErrorMessage.isEmpty + } + + var capabilitiesTask: Task? + var capabilitiesRequest: URLSessionTask? + @Published var accountsTask: Task? + var isLoadingAccounts: Bool { accountsTask != nil } + var accountChangeTask: Task? + var accountChangeID = UUID() + @Published private var access = Access.locked + var isAuthenticated: Bool { access == .unprotected || access == .unlocked } + + weak var presenter: UIViewController? + private var requestContext: NSExtensionContext? + lazy var sessionStore = NCShareSessionStore() + let inputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("NextcloudShare-" + UUID().uuidString, isDirectory: true) + private var inputTask: Task? + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? + + var autoUploadFileName = "" + var autoUploadDirectory = "" + let utilityFileSystem = NCUtilityFileSystem() + let utility = NCUtility() + private var maintenanceMode = false + var banner: LucidBanner? + + /// Starts account setup and attachment import independently, once per request. + /// Protected content remains hidden until passcode authentication succeeds. + func start(context: NSExtensionContext?, presenter: UIViewController) { + guard connectionMonitor == nil else { return } + let monitor = NWPathMonitor() + connectionMonitor = monitor + // A usable network path does not guarantee that the server is reachable. + // Request failures are still reported separately by the networking calls. + monitor.pathUpdateHandler = { [weak self] path in + let offline = path.status != .satisfied + Task { @MainActor [weak self] in + guard let self, !self.isClosing else { return } + let wasOffline = self.isOffline + self.isOffline = offline + if wasOffline && !offline { self.retryConnection() } + } + } + monitor.start(queue: DispatchQueue(label: "com.nextcloud.share.connection")) + self.requestContext = context + self.presenter = presenter + let versionNextcloudiOS = String(format: NCBrandOptions.shared.textCopyrightNextcloudiOS, utility.getVersionBuild()) + NextcloudKit.configureLogger( + logLevel: NCBrandOptions.shared.disable_log ? .disabled : NCPreferences().log, + logDirectory: NCPreferences.sharedLogDirectory + ) + + nkLog(start: "Start Share session " + versionNextcloudiOS) + + NCBrandColor.shared.createUserColors() + + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in self?.accountsTask?.cancel() } + } + foregroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.isClosing else { return } + self.accountsTask?.cancel() + guard !self.maintenanceMode, self.validateAccount() else { return } + self.resolveUploadConfirmation(nil) + if self.access == .unlocked { self.access = .locked } + self.requestPasscodeIfNeeded() + } + } + // Verify version + let versionApp = utility.getVersionMaintenance() + if let groupDefaults = UserDefaults(suiteName: NCBrandOptions.shared.capabilitiesGroup) { + let lastVersion = groupDefaults.string(forKey: NCGlobal.shared.udLastVersion) + if lastVersion != versionApp { + maintenanceMode = true + return + } + } + + if let account = sessionStore.account?.account { + access = NCPreferences().presentPasscode ? .locked : .unprotected + changeAccount(account) + } + guard let items = requestContext?.inputItems as? [NSExtensionItem] else { + cancel(with: .noFiles) + return + } + isPreparingInput = true + let directory = inputDirectory + inputTask = Task { [weak self] in + defer { + if Task.isCancelled { try? FileManager.default.removeItem(at: directory) } + } + do { + let output = try await NCShareLoaderItemProvider.load(items: items, directory: directory) + guard let self, !Task.isCancelled else { return } + self.fileNames = output.fileNames + self.isPreparingInput = false + if !output.failures.isEmpty || output.fileNames.isEmpty { + let key = output.fileNames.isEmpty ? "_share_import_no_files_" : "_share_import_failed_" + self.inputErrorMessage = ([NSLocalizedString(key, comment: "")] + output.failures).joined(separator: "\n\n") + } + } catch is CancellationError { + // The loader removes its own request directory on cancellation. + } catch { + guard let self, !Task.isCancelled else { return } + self.isPreparingInput = false + self.inputErrorMessage = NSLocalizedString("_share_import_no_files_", comment: "") + "\n\n" + error.localizedDescription + } + } + } + + func validateAccountAndRequestPasscode() { + guard !isClosing else { return } + guard !maintenanceMode else { + return showAlert(description: "_version_mismatch_error_") { [weak self] in + self?.cancel(with: .versionMismatch) + } + } + guard validateAccount() else { return } + requestPasscodeIfNeeded() + } + + func retryConnection() { + guard !isOffline, isAuthenticated, !isClosing, !isSwitchingAccount, !isPreparingUpload else { return } + if serverCapabilities == nil { + retryCapabilities() + } else { + for row in visibleFolderRows where expandedFolderURLs.contains(row.folder.serverUrl) { + requestFolderChildren(row.folder, refresh: true) + } + } + } + + func loadThumbnail(named fileName: String) { + guard !isClosing, fileThumbnails[fileName] == nil, thumbnailRequests[fileName] == nil, + !failedThumbnails.contains(fileName), fileNames.contains(fileName) else { return } + let request = QLThumbnailGenerator.Request( + fileAt: inputFileURL(named: fileName), size: CGSize(width: 36, height: 36), + scale: presenter?.view.window?.screen.scale ?? 2, representationTypes: .all) + thumbnailRequests[fileName] = request + QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { [weak self] representation, _ in + let image = representation?.uiImage + Task { @MainActor [weak self] in + guard let self, !self.isClosing, self.thumbnailRequests[fileName] === request else { return } + self.thumbnailRequests[fileName] = nil + guard self.fileNames.contains(fileName) else { return } + if let image { + // Keep extension memory bounded even when sharing many files. + if self.fileThumbnails.count >= 64, let oldest = self.fileThumbnails.keys.first { + self.fileThumbnails[oldest] = nil + } + self.fileThumbnails[fileName] = image + } else { + self.failedThumbnails.insert(fileName) + } + } + } + } + + func fileIcon(named fileName: String) -> String { + guard let type = UTType(filenameExtension: (fileName as NSString).pathExtension) else { return "doc" } + if type.conforms(to: .image) { return "photo" } + if type.conforms(to: .movie) { return "film" } + if type.conforms(to: .audio) { return "music.note" } + if type.conforms(to: .pdf) { return "doc.richtext" } + if type.conforms(to: .archive) { return "doc.zipper" } + if type.conforms(to: .text) { return "doc.text" } + return "doc" + } + + private func discardThumbnail(named fileName: String) { + if let request = thumbnailRequests.removeValue(forKey: fileName) { + QLThumbnailGenerator.shared.cancel(request) + } + fileThumbnails[fileName] = nil + failedThumbnails.remove(fileName) + } + + func inputFileURL(named fileName: String) -> URL { + inputDirectory.appendingPathComponent(fileName) + } + + func acknowledgeInputError() { + inputErrorMessage = "" + if fileNames.isEmpty { cancel(with: .noFiles) } + } + + isolated deinit { + stopRequestWork() + if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } + if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } + } + + private func stopRequestWork() { + connectionMonitor?.cancel() + for request in thumbnailRequests.values { QLThumbnailGenerator.shared.cancel(request) } + inputTask?.cancel() + uploadTask?.cancel() + accountsTask?.cancel() + accountChangeTask?.cancel() + capabilitiesTask?.cancel() + capabilitiesRequest?.cancel() + folderSelectionTask?.cancel() + for task in folderTasks.values { task.cancel() } + for request in folderRequests.values { request.cancel() } + if !isPreparingInput { try? FileManager.default.removeItem(at: inputDirectory) } + } + + private func validateAccount() -> Bool { + guard access != .accountUnavailable else { return false } + guard !NCPasscodeModel.isPasscodeReset else { + return showNoAccountAlert() + } + + if let account = sessionStore.account?.account { + // Re-read the selected account after foregrounding. It may have been + // removed by the host app while this share request was suspended. + guard NCManageDatabase.shared.getTableAccount(account: account) != nil else { + return showNoAccountAlert() + } + return true + } + + guard let activeAccount = NCManageDatabase.shared.getActiveTableAccount() else { + return showNoAccountAlert() + } + + changeAccount(activeAccount.account) + return true + } + + private func showNoAccountAlert() -> Bool { + let wasPresentingPasscode = access == .presentingPasscode + access = .accountUnavailable + accountChangeID = UUID() + accountChangeTask?.cancel() + accountsTask?.cancel() + uploadTask?.cancel() + pendingUploadConfirmation = nil + isShowingUploadConfirmation = false + clearAccountView() + banner?.dismiss() + if wasPresentingPasscode { + cancel(with: .noAccount) + } else { + showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } + } + return false + } + + func clearAccountView(preservingContent: Bool = false) { + capabilitiesTask?.cancel() + capabilitiesRequest?.cancel() + serverCapabilities = nil + isLoadingCapabilities = false + capabilitiesErrorMessage = "" + resetFolderBrowser(preservingContent: preservingContent) + if !preservingContent { + accounts = [] + } + } + + /// Completes or cancels the extension request once and stops its pending work. + /// An in-flight importer cleans up its directory after observing cancellation. + func cancel(with error: NCShareRequestError? = nil) { + guard !isClosing else { return } + isClosing = true + alertDismissAction = nil + stopRequestWork() + thumbnailRequests.removeAll() + fileThumbnails.removeAll() + isShowingUploadConfirmation = false + pendingUploadConfirmation = nil + if let error { + requestContext?.cancelRequest(withError: error) + } else { + requestContext?.completeRequest(returningItems: requestContext?.inputItems, completionHandler: nil) + } + } + + func showAlert(description: String, onDismiss: (() -> Void)? = nil) { + alertMessage = NSLocalizedString(description.replacingOccurrences(of: "\t", with: "\n"), comment: "") + alertDismissAction = onDismiss + } + + func acknowledgeAlert() { + let action = alertDismissAction + alertMessage = "" + alertDismissAction = nil + action?() + } + + func createFolder() { + guard canUseUploadDestination, let folder = selectedFolder, let capabilities = serverCapabilities, + let presenter, presenter.viewIfLoaded?.window != nil, + presenter.presentedViewController == nil, + !presenter.isBeingDismissed else { return } + let session = sessionStore.getSession() + let browserID = folderBrowserID + let alert = UIAlertController.createFolderWith( + serverUrl: folder.serverUrl, session: session, capabilities: capabilities, + completion: { [weak self] error, name in + guard let self, self.isCurrentBrowser(browserID, account: session.account) else { return } + guard error == .success else { + if let message = error.userFriendlyMessage(for: .createFolder(folderName: name)) { + self.showAlert(description: message) + } + return + } + self.isLoading = true + self.folderSelectionTask = Task { @MainActor [weak self] in + guard let self else { return } + defer { if self.folderBrowserID == browserID { self.isLoading = false } } + // Recents can point into a collapsed, not-yet-loaded branch. + // Load and expand its ancestors before scrolling to the new row. + guard var branch = self.rootFolder else { return } + while true { + guard self.isCurrentBrowser(browserID, account: session.account) else { return } + self.expandedFolderURLs.insert(branch.serverUrl) + if let task = self.folderTasks[branch.serverUrl] { await task.value } + guard self.isCurrentBrowser(browserID, account: session.account) else { return } + self.requestFolderChildren(branch, refresh: branch.serverUrl == folder.serverUrl) + if let task = self.folderTasks[branch.serverUrl] { await task.value } + guard self.isCurrentBrowser(browserID, account: session.account), + self.folderLoadErrors[branch.serverUrl] == nil else { return } + if branch.serverUrl == folder.serverUrl { break } + guard let child = self.folderChildren[branch.serverUrl]?.first(where: { + folder.serverUrl == $0.serverUrl || folder.serverUrl.hasPrefix($0.serverUrl + "/") + }) else { return } + branch = child + } + guard let created = self.folderChildren[folder.serverUrl]?.first(where: { $0.name == name }), + self.isAuthenticated else { return } + self.selectedFolder = created + self.isRecentFolderSelected = false + self.expandedFolderURLs.insert(created.serverUrl) + self.requestFolderChildren(created) + self.folderScrollTarget = created.serverUrl + } + }) + presenter.present(alert, animated: true) + } + + func showRenameFileDialog(named fileName: String) { + guard !isClosing, isAuthenticated, !isPreparingUpload, fileNames.contains(fileName), + let capabilities = serverCapabilities else { return } + let account = sessionStore.getSession().account + let alert = UIAlertController.renameFile(fileName: fileName, capabilities: capabilities, account: account) { [weak self] newFileName in + self?.renameFile(oldName: fileName, newName: newFileName) + } + + presenter?.present(alert, animated: true) + } + + private func renameFile(oldName: String, newName: String) { + guard !isClosing, isAuthenticated, !isPreparingUpload else { return } + guard newName != oldName else { return } + guard !newName.isEmpty, let fileIx = self.fileNames.firstIndex(of: oldName), + !self.fileNames.contains(newName), + newName == (newName as NSString).lastPathComponent, + newName != ".", newName != "..", + utilityFileSystem.moveFile(atPath: inputFileURL(named: oldName).path, toPath: inputFileURL(named: newName).path) else { + return showAlert(description: "'\(oldName)' -> '\(newName)'") + } + + discardThumbnail(named: oldName) + fileNames[fileIx] = newName + + } + + func removeFile(named fileName: String) { + guard !isClosing, isAuthenticated, !isPreparingUpload else { return } + guard let index = self.fileNames.firstIndex(of: fileName) else { + return showAlert(description: fileName) + } + discardThumbnail(named: fileName) + try? FileManager.default.removeItem(at: inputFileURL(named: fileName)) + self.fileNames.remove(at: index) + if self.fileNames.isEmpty { + cancel(with: .noFiles) + } + } + + /// Require authentication once per foreground session without reopening after dismissal. + private func requestPasscodeIfNeeded() { + guard access != .presentingPasscode, access != .unlocked else { return } + if !NCPreferences().presentPasscode { + access = .unprotected + return + } + access = .locked + // The sheet's onDismiss resumes the passcode flow after its transition. + guard pendingUploadConfirmation == nil, let presenter else { return } + access = .presentingPasscode + NCPasscodeView.present(from: presenter, allowCancel: true) { [weak self] result in + guard let self, !self.isClosing else { return } + self.access = .locked + guard result == .authenticated else { return self.cancel(with: .noAccount) } + guard self.validateAccount() else { return } + self.access = .unlocked + if self.accounts.isEmpty { self.loadAccounts() } + } + } +} + +/// A UI snapshot, so SwiftUI does not retain live Realm objects. +struct NCShareFolderItem: Identifiable { + var id: String { serverUrl } + let ocId: String + let name: String + let serverUrl: String + let path: String + let image: UIImage? + let isEncrypted: Bool + var isFavorite = false +} + +struct NCShareFolderRow: Identifiable { + var id: String { folder.id } + let folder: NCShareFolderItem + let depth: Int +} + +enum NCShareRequestError: Error { + case cancel, fileUpload, noAccount, noFiles, versionMismatch +} + +struct NCShareUploadItem: Sendable { + let sourceName: String + let fileName: String +} + +struct NCShareUploadConfirmation: Sendable { + let account: String + let serverUrl: String + let items: [NCShareUploadItem] + let replacementFileName: String? + let replacementETag: String? + let requiresConfirmation: Bool + + var isBatch: Bool { items.count > 1 } + var renamedItems: [NCShareUploadItem] { items.filter { $0.sourceName != $0.fileName } } +} + +enum NCShareUploadChoice: Equatable, Sendable { + case replace, keepBoth +} + +/// Account picker data contains no live Realm objects or mutable account flags. +struct NCShareAccountItem: Identifiable { + let id: String + let name: String + let user: String + let serverUrl: String + let isSelected: Bool + var avatar: UIImage? +} + +#if DEBUG && SHARE_PREVIEWS +extension NCShareViewModel { + /// Static preview state. The entry controller never starts a share request. + /// Session storage stays lazy so constructing this model does not read accounts. + static func preview() -> NCShareViewModel { + let model = NCShareViewModel() + model.serverCapabilities = NKCapabilities.Capabilities() + model.access = .unprotected + let home = "https://cloud.example.com/remote.php/dav/files/marino" + let root = NCShareFolderItem(ocId: "root", name: "My files", serverUrl: home, path: "/", image: UIImage(systemName: "folder.fill"), isEncrypted: false) + let documents = NCShareFolderItem(ocId: "documents", name: "Documents", serverUrl: home + "/Documents", path: "/Documents", image: UIImage(systemName: "folder.fill"), isEncrypted: false, isFavorite: true) + let photos = NCShareFolderItem(ocId: "photos", name: "Photos", serverUrl: home + "/Photos", path: "/Photos", image: UIImage(systemName: "folder.fill"), isEncrypted: false) + let work = NCShareFolderItem(ocId: "work", name: "Work", serverUrl: home + "/Documents/Work", path: "/Documents/Work", image: UIImage(systemName: "folder.fill"), isEncrypted: false) + model.selectedFolder = documents + model.rootFolder = root + model.folderChildren = [home: [documents, photos], documents.serverUrl: [work]] + model.expandedFolderURLs = [home, documents.serverUrl] + model.loadedFolderURLs = [home, documents.serverUrl] + model.recentFolders = [documents, photos] + model.fileNames = ["Report.pdf", "Photo.jpg", "Notes.txt"] + model.accounts = [ + NCShareAccountItem(id: "personal", name: "Personal", user: "marino", serverUrl: "https://cloud.example.com", isSelected: true), + NCShareAccountItem(id: "work", name: "Work", user: "marino.work", serverUrl: "https://team.example.com", isSelected: false) + ] + return model + } +} +#endif diff --git a/Share/NCShareCell.swift b/Share/NCShareCell.swift deleted file mode 100644 index 898f980850..0000000000 --- a/Share/NCShareCell.swift +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-FileCopyrightText: 2021 Henrik Storch -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit - -protocol NCShareCellDelegate: AnyObject { - func removeFile(named fileName: String) - func showRenameFileDialog(named fileName: String, account: String) - func renameFile(oldName: String, newName: String, account: String) -} - -class NCShareCell: UITableViewCell { - @IBOutlet weak var imageCell: UIImageView! - @IBOutlet weak var fileNameCell: UILabel! - @IBOutlet weak var fileNameExtensionCell: UILabel! - @IBOutlet weak var moreButton: UIButton! - @IBOutlet weak var sizeCell: UILabel! - weak var delegate: (NCShareCellDelegate & UIViewController)? - var fileName: String = "" - var iconName: String = "" - var account: String = "" - let utilityFileSystem = NCUtilityFileSystem() - let utility = NCUtility() - - func setup(fileName: String, iconName: String, account: String) { - self.fileName = fileName - self.account = account - self.iconName = iconName - - backgroundColor = .systemBackground - imageCell?.layer.cornerRadius = 6 - imageCell?.layer.masksToBounds = true - - if let image = UIImage.downsample( - imageAt: URL(fileURLWithPath: NSTemporaryDirectory() + fileName), - to: CGSize(width: 80, height: 80), - scale: traitCollection.displayScale - ) { - imageCell.image = image - imageCell.contentMode = .scaleAspectFill - } else { - imageCell.image = utility.loadImage(named: iconName, useTypeIconFile: true, account: account) - imageCell.contentMode = .scaleAspectFit - } - - setBidiSafeFilename(fileName, isDirectory: false, titleLabel: fileNameCell, extensionLabel: fileNameExtensionCell) - - let fileSize = utilityFileSystem.getFileSize(filePath: (NSTemporaryDirectory() + fileName)) - sizeCell?.text = utilityFileSystem.transformedSize(fileSize) - - moreButton?.setImage(NCImageCache.shared.getImageButtonMore(), for: .normal) - } - - @IBAction func buttonTapped(_ sender: Any) { - let nsName = fileName as NSString - let ext = nsName.pathExtension - let base = nsName.deletingPathExtension - let displayName = ext.isEmpty || base.isEmpty ? fileName : base + "." + ext - let alertController = UIAlertController(title: "", message: displayName, preferredStyle: .alert) - - alertController.addAction(UIAlertAction(title: NSLocalizedString("_rename_file_", comment: ""), style: .default) { _ in - self.delegate?.showRenameFileDialog(named: self.fileName, account: self.account) - }) - - alertController.addAction(UIAlertAction(title: NSLocalizedString("_remove_file_", comment: ""), style: .default) { _ in - self.delegate?.removeFile(named: self.fileName) - }) - - alertController.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel) { _ in }) - delegate?.present(alertController, animated: true, completion: nil) - } -} diff --git a/Share/NCShareEntryController.swift b/Share/NCShareEntryController.swift new file mode 100644 index 0000000000..68072b6ec8 --- /dev/null +++ b/Share/NCShareEntryController.swift @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import LucidBanner + +/// Loads the SwiftUI interface directly as the extension's principal controller. +@MainActor +final class NCShareEntryController: UIHostingController { + private let model: NCShareViewModel + private var hasAppeared = false + + // The extension system instantiates the principal class through Objective-C. + @objc init() { + let model = NCShareViewModel() + self.model = model + super.init(rootView: NCShareView(model: model)) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("This extension is initialized through NSExtensionPrincipalClass.") + } + + override func viewDidLoad() { + super.viewDidLoad() + // Prepare local state before the first appearance to avoid an empty initial screen. + model.start(context: extensionContext, presenter: self) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Scene-bound banners and passcode presentation require an attached window. + // Returning from a presented sheet must not restart the share request. + guard !hasAppeared, !model.isClosing else { return } + hasAppeared = true + if let windowScene = view.window?.windowScene { + model.banner = LucidBannerRegistry.shared.banner(for: windowScene) + } + model.validateAccountAndRequestPasscode() + } +} diff --git a/Share/NCShareExtension+DataSource.swift b/Share/NCShareExtension+DataSource.swift deleted file mode 100644 index 03836ab45f..0000000000 --- a/Share/NCShareExtension+DataSource.swift +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-FileCopyrightText: 2021 Henrik Storch -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit - -// MARK: - Collection View (target folder) - -extension NCShareExtension: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - Task { - guard let tblAccount = NCShareExtensionData.shared.getTblAccoun(), - let metadata = self.dataSource.getMetadata(indexPath: indexPath) else { - return self.showAlert(description: "_invalid_url_") - } - let serverUrl = self.utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) - - if metadata.e2eEncrypted && !NCPreferences().isEndToEndEnabled(account: tblAccount.account) { - self.showAlert(title: "_info_", description: "_e2e_goto_settings_for_enable_") - return - } - let capabilities = await NKCapabilities.shared.getCapabilities(for: tblAccount.account) - - if let fileNameError = FileNameValidator.checkFileName(metadata.fileNameView, account: tblAccount.account, capabilities: capabilities) { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync(message: message, presenter: self) - return - } - - self.serverUrl = serverUrl - self.setNavigationBar(navigationTitle: metadata.fileNameView) - - await self.reloadData() - await self.loadFolder() - } - } - - func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { - if kind == UICollectionView.elementKindSectionHeader { - let session = NCShareExtensionData.shared.getSession() - guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFirstHeaderEmptyData", for: indexPath) as? NCSectionFirstHeaderEmptyData else { return NCSectionFirstHeaderEmptyData() } - if self.dataSourceTask?.state == .running { - header.emptyImage.image = utility.loadImage(named: "wifi", colors: [NCBrandColor.shared.getElement(account: session.account)]) - header.emptyTitle.text = NSLocalizedString("_request_in_progress_", comment: "") - header.emptyDescription.text = "" - } else { - header.emptyImage.image = NCImageCache.shared.getFolder(account: session.account) - header.emptyTitle.text = NSLocalizedString("_files_no_folders_", comment: "") - header.emptyDescription.text = "" - } - return header - } else { - return UICollectionReusableView() - } - } -} - -extension NCShareExtension: UICollectionViewDelegateFlowLayout { - func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { - var height: CGFloat = 0 - if self.dataSource.isEmpty() { - height = NCUtility().getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -50) - } - return CGSize(width: collectionView.frame.width, height: height) - } -} - -extension NCShareExtension: UICollectionViewDataSource { - func numberOfSections(in collectionView: UICollectionView) -> Int { - return dataSource.numberOfSections() - } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return self.dataSource.numberOfItemsInSection(section) - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = (collectionView.dequeueReusableCell(withReuseIdentifier: "listCell", for: indexPath) as? NCListCell)! - guard let metadata = self.dataSource.getMetadata(indexPath: indexPath) else { - return cell - } - - cell.metadata = metadata - cell.setBidiSafeFilename(metadata.fileNameView, isDirectory: metadata.directory, titleLabel: cell.labelTitle, extensionLabel: cell.labelExtension) - cell.labelTitle.textColor = NCBrandColor.shared.textColor - cell.labelExtension?.textColor = NCBrandColor.shared.textColor - cell.setButtonsHidden(true) - - if metadata.directory { - setupDirectoryCell(cell, indexPath: indexPath, with: metadata) - } - - if metadata.favorite { - cell.imageFavorite.image = NCImageCache.shared.getImageFavorite() - } - - cell.writeInfoDateSize(date: metadata.date, size: metadata.size) - - if metadata.isLivePhoto { - cell.imageStatus.image = utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor2]) - } - - cell.setTags(tags: Array(metadata.tags)) - - let accessibilityLabel = [metadata.fileNameView, cell.labelInfo.text, cell.labelSubinfo.text] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: ", ") - cell.setAccessibility(label: accessibilityLabel, value: "") - cell.accessibilityTraits = .button - - cell.separator.isHidden = collectionView.numberOfItems(inSection: indexPath.section) == indexPath.row + 1 - - return cell - } - - func setupDirectoryCell(_ cell: NCListCell, indexPath: IndexPath, with metadata: tableMetadata) { - var isShare = false - var isMounted = false - let session = NCShareExtensionData.shared.getSession() - - if let metadataFolder = metadataFolder { - isShare = metadata.permissions.contains(NCMetadataPermissions.permissionShared) && !metadataFolder.permissions.contains(NCMetadataPermissions.permissionShared) - isMounted = metadata.permissions.contains(NCMetadataPermissions.permissionMounted) && !metadataFolder.permissions.contains(NCMetadataPermissions.permissionMounted) - } - - if metadata.e2eEncrypted { - cell.imageItem.image = NCImageCache.shared.getFolderEncrypted(account: metadata.account) - } else if isShare { - cell.imageItem.image = NCImageCache.shared.getFolderSharedWithMe(account: metadata.account) - } else if !metadata.shareType.isEmpty { - metadata.shareType.contains(3) ? - (cell.imageItem.image = NCImageCache.shared.getFolderPublic(account: metadata.account)) : - (cell.imageItem.image = NCImageCache.shared.getFolderSharedWithMe(account: metadata.account)) - } else if metadata.mountType == "group" { - cell.imageItem.image = NCImageCache.shared.getFolderGroup(account: metadata.account) - } else if isMounted { - cell.imageItem.image = NCImageCache.shared.getFolderExternal(account: metadata.account) - } else if metadata.fileName == autoUploadFileName && metadata.serverUrl == autoUploadDirectory { - cell.imageItem.image = NCImageCache.shared.getFolderAutomaticUpload(account: metadata.account) - } else { - cell.imageItem.image = NCImageCache.shared.getFolder(account: metadata.account) - } - - cell.labelInfo.text = utility.getRelativeDateTitle(metadata.date as Date) - - let lockServerUrl = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) - let tableDirectory = NCManageDatabase.shared.getTableDirectory(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@", session.account, lockServerUrl)) - - // Local image: offline - if tableDirectory != nil && tableDirectory!.offline { - cell.imageLocal?.image = NCImageCache.shared.getImageOfflineFlag() - } - } -} - -// MARK: - Table View (uploading files) - -extension NCShareExtension: UITableViewDelegate { - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return heightRowTableView - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let fileName = filesName[indexPath.row] - let session = NCShareExtensionData.shared.getSession() - - showRenameFileDialog(named: fileName, account: session.account) - } -} - -extension NCShareExtension: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - filesName.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as? NCShareCell else { - return UITableViewCell() - } - - let fileName = filesName[indexPath.row] - let session = NCShareExtensionData.shared.getSession() - - cell.setup(fileName: fileName, iconName: "", account: session.account) - cell.delegate = self - - return cell - } -} diff --git a/Share/NCShareExtension+Files.swift b/Share/NCShareExtension+Files.swift deleted file mode 100644 index ab7db35aea..0000000000 --- a/Share/NCShareExtension+Files.swift +++ /dev/null @@ -1,203 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-FileCopyrightText: 2021 Henrik Storch -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import UIKit -import UniformTypeIdentifiers -import NextcloudKit - -extension NCShareExtension { - func reloadData() async { - let session = NCShareExtensionData.shared.getSession() - let layoutForView = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, serverUrl: serverUrl) - let showHiddenFiles = NCPreferences().getShowHiddenFiles(account: session.account) - let predicate = showHiddenFiles - ? NSPredicate( - format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true", - session.account, - serverUrl, - NextcloudKit.shared.nkCommonInstance.rootFileName - ) - : NSPredicate( - format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true AND NOT fileName BEGINSWITH[c] %@", - session.account, - serverUrl, - NextcloudKit.shared.nkCommonInstance.rootFileName, - "." - ) - - let metadatas = await NCManageDatabase.shared.getMetadatasAsync(predicate: predicate, - withLayout: layoutForView, - withAccount: session.account) - self.dataSource = NCCollectionViewDataSource(metadatas: metadatas, - layoutForView: layoutForView, - account: session.account) - self.collectionView.reloadData() - } - - @objc func didCreateFolder(_ notification: NSNotification) { - Task { - guard let userInfo = notification.userInfo as NSDictionary?, - let ocId = userInfo["ocId"] as? String, - let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) - else { return } - - self.serverUrl += "/" + metadata.fileName - await self.loadFolder() - self.setNavigationBar(navigationTitle: metadata.fileNameView) - } - } - - func loadFolder() async { - let session = NCShareExtensionData.shared.getSession() - let resultsReadFolder = await NCNetworking.shared.readFolderAsync(serverUrl: serverUrl, account: session.account) { task in - self.dataSourceTask = task - self.collectionView.reloadData() - } - - if resultsReadFolder.error == .success { - self.metadataFolder = resultsReadFolder.metadataFolder - await self.reloadData() - } else if let message = resultsReadFolder.error.userFriendlyMessage() { - self.showAlert(description: message) - } - } -} - -class NCFilesExtensionHandler { - var itemsProvider: [NSItemProvider] = [] - lazy var fileNames: [String] = [] - let dateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd HH-mm-ss-" - return formatter - }() - - @discardableResult - init(items: [NSExtensionItem], completion: @escaping ([String]) -> Void) { - NCUtilityFileSystem().emptyTemporaryDirectory() - var counter = 0 - - self.itemsProvider = items.compactMap({ $0.attachments }).flatMap { $0.filter({ - $0.hasItemConformingToTypeIdentifier(UTType.item.identifier as String) || $0.hasItemConformingToTypeIdentifier("public.url") - }) } - - for (ix, provider) in itemsProvider.enumerated() { - provider.loadItem(forTypeIdentifier: provider.typeIdentifier) { [self] item, error in - defer { - counter += 1 - if counter == itemsProvider.count { completion(self.fileNames) } - } - guard error == nil else { return } - var originalName = (dateFormatter.string(from: Date())) + String(ix) - - if let url = item as? URL, url.isFileURL, !url.lastPathComponent.isEmpty { - originalName = url.lastPathComponent - - if fileNames.contains(originalName) { - let incrementalNumber = NCPreferences().incrementalNumber - originalName = "\(url.deletingPathExtension().lastPathComponent) \(incrementalNumber).\(url.pathExtension)" - } - } - - var fileName: String? - switch item { - case let image as UIImage: - fileName = getItem(image: image, fileName: originalName) - case let url as URL: - fileName = getItem(url: url, fileName: originalName) - case let data as Data: - fileName = getItem(data: data, fileName: originalName, provider: provider) - case let text as String: - fileName = getItem(string: text, fileName: originalName) - default: return - } - - if let fileName, !fileNames.contains(fileName) { - fileNames.append(fileName) - } - } - } - } - - // Image - func getItem(image: UIImage, fileName: String) -> String? { - var fileUrl = URL(fileURLWithPath: NSTemporaryDirectory() + fileName) - if fileUrl.pathExtension.isEmpty { fileUrl.appendPathExtension("png") } - guard let pngImageData = image.pngData(), (try? pngImageData.write(to: fileUrl, options: [.atomic])) != nil - else { return nil } - return fileUrl.lastPathComponent - } - - // URL - // Does not work for directories - func getItem(url: URL, fileName: String) -> String? { - var fileName = fileName - guard url.isFileURL else { - guard !fileNames.contains(url.lastPathComponent) else { return nil } - if !url.deletingPathExtension().lastPathComponent.isEmpty { fileName = url.deletingPathExtension().lastPathComponent } - fileName += "." + (url.pathExtension.isEmpty ? "html" : url.pathExtension) - let filenamePath = NSTemporaryDirectory() + fileName - - do { - let downloadedContent = try Data(contentsOf: url) - guard !FileManager.default.fileExists(atPath: filenamePath) else { return nil } - try downloadedContent.write(to: URL(fileURLWithPath: filenamePath)) - } catch { print(error); return nil } - return fileName - } - - let filenamePath = NSTemporaryDirectory() + fileName - - try? FileManager.default.removeItem(atPath: filenamePath) - - do { - try FileManager.default.copyItem(atPath: url.path, toPath: filenamePath) - - let attr = try FileManager.default.attributesOfItem(atPath: filenamePath) - guard !attr.isEmpty else { return nil } - return fileName - } catch { return nil } - } - - // Data - func getItem(data: Data, fileName: String, provider: NSItemProvider) -> String? { - guard !data.isEmpty else { return nil } - var fileName = fileName - - if let url = URL(string: fileName), !url.pathExtension.isEmpty { - fileName = url.lastPathComponent - } else if let name = provider.suggestedName { - fileName = name - } else if let ext = provider.registeredTypeIdentifiers.last?.split(separator: ".").last { - fileName += "." + ext - } // else: no file information, use default name without ext - - // when sharing images in safari only data is retuned. - // also, when sharing option "Automatic" is slected extension will return both raw data and a url, which will be downloaded, causing the image to appear twice with different names - if let image = UIImage(data: data) { - return getItem(image: image, fileName: fileName) - } - - let filenamePath = NSTemporaryDirectory() + fileName - FileManager.default.createFile(atPath: filenamePath, contents: data, attributes: nil) - return fileName - } - - // String - func getItem(string: String, fileName: String) -> String? { - guard !string.isEmpty else { return nil } - let filenamePath = NSTemporaryDirectory() + fileName + ".txt" - FileManager.default.createFile(atPath: filenamePath, contents: string.data(using: String.Encoding.utf8), attributes: nil) - return fileName - } -} - -extension NSItemProvider { - var typeIdentifier: String { - if hasItemConformingToTypeIdentifier("public.url") { return "public.url" } else - if hasItemConformingToTypeIdentifier(UTType.item.identifier as String) { return UTType.item.identifier as String } else { return "" } - } -} diff --git a/Share/NCShareExtension+NCAccountRequestDelegate.swift b/Share/NCShareExtension+NCAccountRequestDelegate.swift deleted file mode 100644 index b1de031237..0000000000 --- a/Share/NCShareExtension+NCAccountRequestDelegate.swift +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-FileCopyrightText: 2021 Henrik Storch -// SPDX-License-Identifier: GPL-3.0-or-later - -import NextcloudKit -import UIKit - -extension NCShareExtension: NCAccountRequestDelegate { - - // MARK: - Account - - func showAccountPicker() { - Task { - let accounts = await NCManageDatabase.shared.getAllAccountOrderAliasAsync() - let session = NCShareExtensionData.shared.getSession() - - guard accounts.count > 1, - let vcAccountRequest = UIStoryboard(name: "NCAccountRequest", bundle: nil).instantiateInitialViewController() as? NCAccountRequest else { - return - } - - // Only here change the active account - for account in accounts { - account.active = account.account == session.account - } - - vcAccountRequest.activeAccount = session.account - vcAccountRequest.accounts = accounts.sorted { sorg, dest -> Bool in - return sorg.active && !dest.active - } - vcAccountRequest.enableTimerProgress = false - vcAccountRequest.enableAddAccount = false - vcAccountRequest.delegate = self - vcAccountRequest.dismissDidEnterBackground = true - - let maximumHeight = view.safeAreaLayoutGuide.layoutFrame.height * 0.8 - let height = min(CGFloat(accounts.count * Int(vcAccountRequest.heightCell) + 45), maximumHeight) - - let popup = NCPopupViewController(contentController: vcAccountRequest, popupWidth: 300, popupHeight: height + 20) - - self.present(popup, animated: true) - - } - } - - func accountRequestAddAccount() { } - - func accountRequestChangeAccount(account: String, controller: UIViewController?) { - Task { - let session = await NCShareExtensionData.shared.setSessionAccount(account) - guard let tblAccount = NCShareExtensionData.shared.getTblAccoun() else { - cancel(with: NCShareExtensionError.noAccount) - return - } - showAccountView() - - NextcloudKit.shared.setup(groupIdentifier: NCBrandOptions.shared.capabilitiesGroup, delegate: NCNetworking.shared) - NextcloudKit.shared.appendSession(account: tblAccount.account, - urlBase: tblAccount.urlBase, - user: tblAccount.user, - userId: tblAccount.userId, - password: NCPreferences().getPassword(account: tblAccount.account), - userAgent: userAgent, - httpMaximumConnectionsPerHost: NCBrandOptions.shared.httpMaximumConnectionsPerHost, - httpMaximumConnectionsPerHostInDownload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInDownload, - httpMaximumConnectionsPerHostInUpload: NCBrandOptions.shared.httpMaximumConnectionsPerHostInUpload, - groupIdentifier: NCBrandOptions.shared.capabilitiesGroup) - - autoUploadFileName = NCManageDatabase.shared.getAccountAutoUploadFileName(account: account) - autoUploadDirectory = NCManageDatabase.shared.getAccountAutoUploadDirectory(account: session.account, - urlBase: session.urlBase, - userId: session.userId) - serverUrl = utilityFileSystem.getHomeServer(session: session) - - setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) - - await reloadData() - await loadFolder() - } - } -} - -extension NCShareExtension: NCCreateFormUploadConflictDelegate { - func dismissCreateFormUploadConflict(metadatas: [tableMetadata]?) { - guard let metadatas else { - uploadMetadata.removeAll() - return - } - - self.uploadMetadata.append(contentsOf: metadatas) - Task { - await uploadAndExit() - } - } -} - -extension NCShareExtension: NCShareCellDelegate { - func showRenameFileDialog(named fileName: String, account: String) { - guard let capabilities = NCNetworking.shared.capabilities[account] else { - return - } - let alert = UIAlertController.renameFile(fileName: fileName, capabilities: capabilities, account: account) { [self] newFileName in - renameFile(oldName: fileName, newName: newFileName, account: account) - } - - present(alert, animated: true) - } - - func renameFile(oldName: String, newName: String, account: String) { - guard let fileIx = self.filesName.firstIndex(of: oldName), - !self.filesName.contains(newName), - utilityFileSystem.moveFile(atPath: (NSTemporaryDirectory() + oldName), toPath: (NSTemporaryDirectory() + newName)) else { - return showAlert(title: "_single_file_conflict_title_", description: "'\(oldName)' -> '\(newName)'") - } - - filesName[fileIx] = newName - tableView.reloadData() - } - - func removeFile(named fileName: String) { - guard let index = self.filesName.firstIndex(of: fileName) else { - return showAlert(title: "_file_not_found_", description: fileName) - } - self.filesName.remove(at: index) - if self.filesName.isEmpty { - cancel(with: NCShareExtensionError.noFiles) - } else { - self.setCommandView() - } - } -} diff --git a/Share/NCShareExtension.swift b/Share/NCShareExtension.swift deleted file mode 100644 index a7b2f68c4f..0000000000 --- a/Share/NCShareExtension.swift +++ /dev/null @@ -1,601 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-FileCopyrightText: 2021 Henrik Storch -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import UniformTypeIdentifiers -import NextcloudKit -import LucidBanner -import SwiftUI - -enum NCShareExtensionError: Error { - case cancel, fileUpload, noAccount, noFiles, versionMismatch -} - -class NCShareExtension: UIViewController { - @IBOutlet weak var collectionView: UICollectionView! - @IBOutlet weak var tableView: UITableView! - @IBOutlet weak var cancelButton: UIBarButtonItem! - @IBOutlet weak var separatorView: UIView! - @IBOutlet weak var commandView: UIView! - @IBOutlet weak var separatorHeightConstraint: NSLayoutConstraint! - @IBOutlet weak var commandViewHeightConstraint: NSLayoutConstraint! - - @IBOutlet weak var createFolderView: UIView! - @IBOutlet weak var createFolderImage: UIImageView! - @IBOutlet weak var createFolderLabel: UILabel! - @IBOutlet weak var uploadView: UIView! - @IBOutlet weak var uploadImage: UIImageView! - @IBOutlet weak var uploadLabel: UILabel! - - // ------------------------------------------------------------- - var serverUrl = "" - var filesName: [String] = [] - // ------------------------------------------------------------- - - let keyLayout = NCGlobal.shared.layoutViewShareExtension - var metadataFolder: tableMetadata? - var dataSourceTask: URLSessionTask? - var dataSource = NCCollectionViewDataSource() - let heightRowTableView: CGFloat = 50 - let heightCommandView: CGFloat = 170 - var autoUploadFileName = "" - var autoUploadDirectory = "" - var progress: CGFloat = 0 - var counterUploaded: Int = 0 - var uploadMetadata: [tableMetadata] = [] - let utilityFileSystem = NCUtilityFileSystem() - let utility = NCUtility() - let global = NCGlobal.shared - var maintenanceMode: Bool = false - var token: Int? - var banner: LucidBanner? - var sceneIdentifier: String = UUID().uuidString - private var isPresentingNoAccountAlert = false - private var isPresentingPasscode = false - private var isPasscodeAuthenticated = false - - // MARK: - View Life Cycle - - override func viewDidLoad() { - super.viewDidLoad() - - collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") - collectionView.register(UINib(nibName: "NCListCell", bundle: nil), forCellWithReuseIdentifier: "listCell") - collectionView.collectionViewLayout = NCListLayout() - - commandView.backgroundColor = .secondarySystemBackground - separatorView.backgroundColor = .separator - separatorHeightConstraint.constant = 0.5 - - tableView.separatorColor = .separator - tableView.layer.cornerRadius = 10 - tableView.tableFooterView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: 1))) - commandViewHeightConstraint.constant = heightCommandView - - cancelButton.title = NSLocalizedString("_cancel_", comment: "") - - createFolderView.layer.cornerRadius = 10 - createFolderImage.image = utility.loadImage(named: "folder.badge.plus", colors: [NCBrandColor.shared.iconImageColor]) - createFolderLabel.text = NSLocalizedString("_create_folder_", comment: "") - let createFolderGesture = UITapGestureRecognizer(target: self, action: #selector(actionCreateFolder(_:))) - createFolderView.addGestureRecognizer(createFolderGesture) - - uploadView.layer.cornerRadius = 10 - - uploadLabel.text = NSLocalizedString("_upload_", comment: "") - uploadLabel.textColor = .systemBlue - let uploadGesture = UITapGestureRecognizer(target: self, action: #selector(actionUpload(_:))) - uploadView.addGestureRecognizer(uploadGesture) - - let versionNextcloudiOS = String(format: NCBrandOptions.shared.textCopyrightNextcloudiOS, utility.getVersionBuild()) - NextcloudKit.configureLogger( - logLevel: NCBrandOptions.shared.disable_log ? .disabled : NCPreferences().log, - logDirectory: NCPreferences.sharedLogDirectory - ) - - nkLog(start: "Start Share session " + versionNextcloudiOS) - - NCBrandColor.shared.createUserColors() - - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: Self, _) in - guard !self.maintenanceMode else { - return - } - self.updateAppearance() - } - - NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in - guard !self.maintenanceMode, - self.validateAccount() else { - return - } - - self.isPasscodeAuthenticated = false - self.requestPasscodeIfNeeded() - } - - // Verify version - let versionApp = NCUtility().getVersionMaintenance() - if let groupDefaults = UserDefaults(suiteName: NCBrandOptions.shared.capabilitiesGroup) { - let lastVersion = groupDefaults.string(forKey: NCGlobal.shared.udLastVersion) - if lastVersion != versionApp { - maintenanceMode = true - return - } - } - - if let account = NCShareExtensionData.shared.getTblAccoun()?.account { - accountRequestChangeAccount(account: account, controller: nil) - } - - NCNetworking.shared.setupScene(sceneIdentifier: sceneIdentifier, controller: self) - - if let windowScene = view.window?.windowScene { - banner = LucidBannerRegistry.shared.banner(for: windowScene) - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - // Verify version - guard !maintenanceMode else { - return showAlert(description: "_version_mismatch_error_") { - self.cancel(with: .versionMismatch) - } - } - - guard validateAccount() else { - return - } - - guard let inputItems = extensionContext?.inputItems as? [NSExtensionItem] else { - cancel(with: .noFiles) - return - } - - Task { @MainActor in - NCFilesExtensionHandler(items: inputItems) { fileNames in - self.filesName = fileNames - DispatchQueue.main.async { - self.setCommandView() - } - } - - self.requestPasscodeIfNeeded() - - self.collectionView.reloadData() - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - coordinator.animate(alongsideTransition: nil) { _ in - self.collectionView?.collectionViewLayout.invalidateLayout() - } - } - - private func updateAppearance() { - collectionView.visibleCells.forEach { $0.setNeedsLayout() } - tableView.visibleCells.forEach { $0.setNeedsLayout() } - } - - private func validateAccount() -> Bool { - guard !NCPasscodeModel.isPasscodeReset else { - return showNoAccountAlert() - } - - if let account = NCShareExtensionData.shared.getTblAccoun()?.account, - NCManageDatabase.shared.getTableAccount(account: account) != nil { - return true - } - - guard let activeAccount = NCManageDatabase.shared.getActiveTableAccount() else { - return showNoAccountAlert() - } - - clearAccountView() - accountRequestChangeAccount(account: activeAccount.account, controller: nil) - return true - } - - private func showNoAccountAlert() -> Bool { - clearAccountView() - if !isPresentingNoAccountAlert { - isPresentingNoAccountAlert = true - showAlert(description: "_no_active_account_") { - self.cancel(with: .noAccount) - } - } - return false - } - - private func clearAccountView() { - dataSourceTask?.cancel() - dataSourceTask = nil - dataSource = NCCollectionViewDataSource() - metadataFolder = nil - - collectionView.isHidden = true - tableView.isHidden = true - commandView.isHidden = true - separatorView.isHidden = true - navigationItem.leftBarButtonItems = nil - navigationItem.title = NCBrandOptions.shared.brand - view.layoutIfNeeded() - } - - func showAccountView() { - collectionView.isHidden = false - tableView.isHidden = false - commandView.isHidden = false - separatorView.isHidden = false - } - - // MARK: - - - func cancel(with error: NCShareExtensionError? = nil) { - if let error { - extensionContext?.cancelRequest(withError: error) - } else { - extensionContext?.completeRequest(returningItems: extensionContext?.inputItems, completionHandler: nil) - } - } - - func showAlert(title: String = "_error_", description: String, onDismiss: (() -> Void)? = nil) { - let description = description.replacingOccurrences(of: "\t", with: "\n") - let alertController = UIAlertController(title: NSLocalizedString(title, comment: ""), message: NSLocalizedString(description, comment: ""), preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - onDismiss?() - })) - self.present(alertController, animated: true) - } - - func setNavigationBar(navigationTitle: String) { - guard let tblAccount = NCShareExtensionData.shared.getTblAccoun() else { - return - } - let session = NCShareExtensionData.shared.getSession() - - navigationItem.title = navigationTitle - cancelButton.title = NSLocalizedString("_cancel_", comment: "") - - // BACK BUTTON - let backButton = UIButton(type: .custom) - backButton.setImage(UIImage(named: "back"), for: .normal) - backButton.tintColor = .systemBlue - backButton.semanticContentAttribute = .forceLeftToRight - backButton.setTitle(" " + NSLocalizedString("_back_", comment: ""), for: .normal) - backButton.setTitleColor(.systemBlue, for: .normal) - backButton.action(for: .touchUpInside) { _ in - while self.serverUrl.last != "/" { self.serverUrl.removeLast() } - self.serverUrl.removeLast() - Task { - await self.reloadData() - } - var navigationTitle = (self.serverUrl as NSString).lastPathComponent - if self.utilityFileSystem.getHomeServer(session: session) == self.serverUrl { - navigationTitle = NCBrandOptions.shared.brand - } - self.setNavigationBar(navigationTitle: navigationTitle) - } - - let image = utility.loadUserImage(for: tblAccount.user, displayName: tblAccount.displayName, urlBase: tblAccount.urlBase) - let profileButton = UIButton(type: .custom) - profileButton.setImage(image, for: .normal) - - if serverUrl == utilityFileSystem.getHomeServer(session: session) { - var title = " " - if !tblAccount.alias.isEmpty { - title += tblAccount.alias - } else { - title += tblAccount.displayName - } - - profileButton.setTitle(title, for: .normal) - profileButton.setTitleColor(.systemBlue, for: .normal) - } - - profileButton.semanticContentAttribute = .forceLeftToRight - profileButton.sizeToFit() - profileButton.action(for: .touchUpInside) { _ in - self.showAccountPicker() - } - var navItems = [UIBarButtonItem(customView: profileButton)] - if serverUrl != utilityFileSystem.getHomeServer(session: session) { - let space = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) - space.width = 20 - navItems.append(contentsOf: [UIBarButtonItem(customView: backButton), space]) - } - navigationItem.setLeftBarButtonItems(navItems, animated: true) - } - - func setCommandView() { - guard !filesName.isEmpty else { - cancel(with: .noFiles) - return - } - let counter = min(CGFloat(filesName.count), 3) - self.commandViewHeightConstraint.constant = heightCommandView + (self.heightRowTableView * counter) - - if filesName.count <= 3 { - self.tableView.isScrollEnabled = false - } - uploadLabel.text = NSLocalizedString("_upload_", comment: "") + " \(filesName.count) " + NSLocalizedString("_files_", comment: "") - self.tableView.reloadData() - } - - // MARK: ACTION - - @IBAction func actionCancel(_ sender: UIBarButtonItem) { - cancel() - } - - @objc func actionCreateFolder(_ sender: Any?) { - let session = NCShareExtensionData.shared.getSession() - guard let capabilities = NCNetworking.shared.capabilities[session.account] else { - return - } - let alertController = UIAlertController.createFolderWith(serverUrl: serverUrl, session: session, capabilities: capabilities) { error, folderName in - let context = NCErrorContext.createFolder(folderName: folderName) - - if error == .success { - Task { - await self.loadFolder() - } - } else if let message = error.userFriendlyMessage(for: context) { - self.showAlert(title: context.failureTitle ?? "_error_", description: message) - } - } - self.present(alertController, animated: true) - } -} - -// MARK: - Upload -extension NCShareExtension { - @objc func actionUpload(_ sender: Any?) { - Task { @MainActor in - guard let tblAccount = NCShareExtensionData.shared.getTblAccoun(), - let capabilities = NCNetworking.shared.capabilities[tblAccount.account] else { - return - } - guard !filesName.isEmpty else { return showAlert(description: "_files_no_files_") } - let session = NCShareExtensionData.shared.getSession() - - var conflicts: [tableMetadata] = [] - var invalidNameIndexes: [Int] = [] - - for (index, fileName) in filesName.enumerated() { - let newFileName = FileAutoRenamer.rename(fileName, capabilities: capabilities) - - if let fileNameError = FileNameValidator.checkFileName(newFileName, account: tblAccount.account, capabilities: capabilities) { - if filesName.count == 1 { - showRenameFileDialog(named: fileName, account: tblAccount.account) - return - } else { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync(message: message, presenter: self) - - invalidNameIndexes.append(index) - continue - } - } - } - - for index in invalidNameIndexes.reversed() { - filesName.remove(at: index) - } - - for fileName in filesName { - let ocId = NSUUID().uuidString - let toPath = utilityFileSystem.getDirectoryProviderStorageOcId(ocId, - fileName: fileName, - userId: session.userId, - urlBase: session.urlBase) - guard utilityFileSystem.copyFile(atPath: (NSTemporaryDirectory() + fileName), toPath: toPath) else { - continue - } - let metadataForUpload = await NCManageDatabaseCreateMetadata().createMetadataAsync( - fileName: fileName, - ocId: ocId, - serverUrl: serverUrl, - session: session, - sceneIdentifier: self.sceneIdentifier) - - metadataForUpload.session = NCNetworking.shared.sessionUpload - metadataForUpload.sessionSelector = NCGlobal.shared.selectorUploadFileShareExtension - metadataForUpload.size = utilityFileSystem.getFileSize(filePath: toPath) - metadataForUpload.status = NCGlobal.shared.metadataStatusWaitUpload - metadataForUpload.sessionDate = Date() - if NCManageDatabase.shared.getMetadataConflict(account: session.account, serverUrl: serverUrl, fileNameView: fileName, nativeFormat: metadataForUpload.nativeFormat) != nil { - conflicts.append(metadataForUpload) - } else { - uploadMetadata.append(metadataForUpload) - } - } - - tableView.reloadData() - - if !conflicts.isEmpty { - guard let conflict = UIStoryboard(name: "NCCreateFormUploadConflict", bundle: nil).instantiateInitialViewController() as? NCCreateFormUploadConflict - else { return } - - conflict.account = session.account - conflict.serverUrl = self.serverUrl - conflict.metadatasUploadInConflict = conflicts - conflict.delegate = self - self.present(conflict, animated: true, completion: nil) - } else { - await uploadAndExit() - } - } - } - - @MainActor - func uploadAndExit() async { - // A Share extension process can be reused by the system. Treat each - // explicit share operation as a new server-key validation cycle. - await NCNetworkingE2EE.beginNewServerKeyValidationCycle() - - var error: NKError? - var errorContext: NCErrorContext = .generic - guard let window = self.view.window else { - return - } - let payload = LucidBannerPayload(stage: .button, - vPosition: .center, - blocksTouches: true) - (banner, token) = showUploadBanner(windowScene: window.windowScene, - payload: payload, - allowMinimizeOnTap: false, - onButtonTap: { - self.cancel() - }) - - for metadata in self.uploadMetadata { - // BANNER - let payloadUpdate = LucidBannerPayload.Update( - title: NSLocalizedString("_upload_file_", comment: "") + " \(self.counterUploaded + 1) " + NSLocalizedString("_of_", comment: "") + " \(self.filesName.count)", - systemImage: "arrowshape.up.circle", - imageAnimation: .breathe, - progress: 0) - banner?.update(payload: payloadUpdate) - - error = await self.upload(metadata: metadata) - if error != .success { - errorContext = .upload(fileName: metadata.fileNameView) - break - } - } - - if error == .success { - banner?.update(payload: LucidBannerPayload.Update(stage: .success, horizontalLayout: .centered(width: 100)), for: self.token) - } else { - let payload = LucidBannerPayload.Update(title: errorContext.failureTitle ?? NSLocalizedString("_error_", comment: ""), - subtitle: error?.userFriendlyMessage(for: errorContext) ?? "", - stage: .error) - - banner?.update(payload: payload, for: self.token) - } - - Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) - banner?.dismiss() - extensionContext?.completeRequest(returningItems: extensionContext?.inputItems, completionHandler: nil) - } - } - - @MainActor - func upload(metadata: tableMetadata) async -> NKError? { - let session = NCShareExtensionData.shared.getSession() - var error: NKError = .success - - let results = await NKTypeIdentifiers.shared.getInternalType(fileName: metadata.fileNameView, mimeType: metadata.contentType, directory: false, account: session.account) - metadata.contentType = results.mimeType - metadata.iconName = results.iconName - metadata.classFile = results.classFile - metadata.typeIdentifier = results.typeIdentifier - metadata.serverUrlFileName = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) - - // CHUNK - if metadata.size > self.global.chunkSize { - metadata.chunk = self.global.chunkSize - } else { - metadata.chunk = 0 - } - // E2EE - metadata.e2eEncrypted = metadata.isDirectoryE2EE - - self.counterUploaded += 1 - - if metadata.isDirectoryE2EE { - error = await NCNetworkingE2EEUpload().upload(metadata: metadata, - session: session, - controller: self, - banner: banner, - stageBanner: nil, - tokenBanner: self.token) - } else if metadata.chunk > 0 { - banner?.update(payload: LucidBannerPayload.Update(systemImage: "gearshape.arrow.triangle.2.circlepath", - imageAnimation: .rotate), - for: self.token) - let task = Task { () -> (account: String, file: NKFile?, error: NKError) in - let results = await NCNetworking.shared.uploadChunkFile(metadata: metadata) { total, counter in - Task {@MainActor in - self.banner?.update(payload: LucidBannerPayload.Update(progress: Double(counter) / Double(total)), for: self.token) - } - } uploadStart: { _ in - Task {@MainActor in - let payload = LucidBannerPayload.Update( - systemImage: "arrowshape.up.circle", - imageAnimation: .breathe - ) - self.banner?.update(payload: payload, for: self.token) - } - } uploadProgressHandler: { _, _, progress in - Task {@MainActor in - self.banner?.update(payload: LucidBannerPayload.Update(progress: progress), for: self.token) - } - } assembling: { - Task {@MainActor in - let payload = LucidBannerPayload.Update( - systemImage: "gearshape.arrow.triangle.2.circlepath", - imageAnimation: .rotate - ) - self.banner?.update(payload: payload, for: self.token) - } - } - - return results - } - - let results = await task.value - error = results.error - } else { - let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, - fileName: metadata.fileName, - userId: metadata.userId, - urlBase: metadata.urlBase) - - let results = await NCNetworking.shared.uploadFile(account: metadata.account, - fileNameLocalPath: fileNameLocalPath, - serverUrlFileName: metadata.serverUrlFileName, - creationDate: metadata.creationDate as Date, - dateModificationFile: metadata.date as Date) { _ in - } progressHandler: { _, _, fractionCompleted in - Task {@MainActor in - self.banner?.update(payload: LucidBannerPayload.Update(progress: fractionCompleted), - for: self.token) - } - } - error = results.error - } - - return error - } -} - -extension NCShareExtension { - /// Require authentication once per foreground session without reopening after dismissal. - private func requestPasscodeIfNeeded() { - guard NCPreferences().presentPasscode, - !isPasscodeAuthenticated, - !isPresentingPasscode else { return } - isPresentingPasscode = true - // Embed in the content controller; the helper hides the outer navigation bar. - NCPasscodeView.embed(in: self) { [weak self] result in - guard let self else { return } - self.isPresentingPasscode = false - switch result { - case .authenticated: - self.isPasscodeAuthenticated = true - _ = self.validateAccount() - case .cancelled, .reset, .configured, .disabled: - self.cancel(with: .noAccount) - } - } - } -} diff --git a/Share/NCShareExtensionData.swift b/Share/NCShareExtensionData.swift deleted file mode 100644 index 1861a4cfea..0000000000 --- a/Share/NCShareExtensionData.swift +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2025 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit - -class NCShareExtensionData: NSObject { - static let shared = NCShareExtensionData() - - private var tblAccount: tableAccount? - - override init() { - self.tblAccount = NCManageDatabase.shared.getActiveTableAccount() - if let account = self.tblAccount?.account { - Task { @MainActor in - if let capabilities = await NCManageDatabase.shared.getCapabilities(account: account) { - NCBrandColor.shared.settingThemingColor(account: account, capabilities: capabilities) - } - } - } - } - - func setSessionAccount(_ account: String) async -> NCSession.Session { - if account != self.tblAccount?.account, - let tblAccount = await NCManageDatabase.shared.getTableAccountAsync(account: account) { - self.tblAccount = tblAccount - if let capabilities = await NCManageDatabase.shared.getCapabilities(account: account) { - NCBrandColor.shared.settingThemingColor(account: account, capabilities: capabilities) - } - } - - return getSession() - } - - func getSession() -> NCSession.Session { - if let tblAccount { - return NCSession.Session(account: tblAccount.account, urlBase: tblAccount.urlBase, user: tblAccount.user, userId: tblAccount.userId) - } else { - return NCSession.Session(account: "", urlBase: "", user: "", userId: "") - } - } - - func getTblAccoun() -> tableAccount? { - return self.tblAccount - } -} diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift new file mode 100644 index 0000000000..0c9056efbf --- /dev/null +++ b/Share/NCShareLoaderItemProvider.swift @@ -0,0 +1,384 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import UniformTypeIdentifiers + +/// Imports one representation per attachment, in source order. File copies are +/// completed inside provider callbacks, before iOS releases its temporary URLs. +@MainActor +final class NCShareLoaderItemProvider { + struct Output: Sendable { + var fileNames: [String] = [] + var failures: [String] = [] + } + + private enum LoadedURL: Sendable { + case file(String) + case remote(URL) + } + + /// NSItemProvider does not guarantee a completion callback after cancellation. + /// This gate resumes exactly once and synchronizes only callback state. + private final class Pending: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var cancellation: (@Sendable () -> Void)? + private var finished = false + private var cancelled = false + + private var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func install(_ continuation: CheckedContinuation) -> Bool { + lock.lock() + let cancelled = cancelled + if !cancelled { self.continuation = continuation } + lock.unlock() + if cancelled { continuation.resume(throwing: CancellationError()) } + return !cancelled + } + + func setCancellation(_ cancellation: @escaping @Sendable () -> Void) { + lock.lock() + let cancelled = cancelled + if !finished { self.cancellation = cancellation } + lock.unlock() + if cancelled { cancellation() } + } + + func finish(_ operation: () throws -> Value) { + guard !isCancelled else { return } + let result = Result(catching: operation) + lock.lock() + guard !finished else { lock.unlock(); return } + finished = true + let continuation = continuation + self.continuation = nil + cancellation = nil + lock.unlock() + continuation?.resume(with: result) + } + + func cancel() { + lock.lock() + guard !finished else { lock.unlock(); return } + finished = true + cancelled = true + let continuation = continuation + let cancellation = cancellation + self.continuation = nil + self.cancellation = nil + lock.unlock() + cancellation?() + continuation?.resume(throwing: CancellationError()) + } + } + + private static func request(_ start: (Pending) -> (@Sendable () -> Void)) async throws -> Value { + let pending = Pending() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard pending.install(continuation) else { return } + pending.setCancellation(start(pending)) + } + } onCancel: { + pending.cancel() + } + } + + static func load(items: [NSExtensionItem], directory: URL) async throws -> Output { + try Task.checkCancellation() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + do { + var output = Output() + // Sequential imports bound peak memory use and preserve attachment order. + for (index, provider) in items.flatMap({ $0.attachments ?? [] }).enumerated() { + try Task.checkCancellation() + do { + let name = try await load(provider: provider, index: index, directory: directory) + output.fileNames.append(name) + } catch { + let format = NSLocalizedString("_share_import_item_error_", comment: "") + output.failures.append(String(format: format, index + 1, error.localizedDescription)) + } + try Task.checkCancellation() + } + return output + } catch { + try? FileManager.default.removeItem(at: directory) + throw error + } + } + + private static func load(provider: NSItemProvider, index: Int, directory: URL) async throws -> String { + let suggestedName = provider.suggestedName + let fallbackName = "Shared item \(index + 1)" + var lastError: Error = CocoaError(.featureUnsupported) + + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier), + provider.canLoadObject(ofClass: NSURL.self) { + // A file URL is a reference to a file, not data to save as a .file-url. + do { + return try await loadLink(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + try Task.checkCancellation() + } + + // These are alternative representations of the same attachment, not + // separate files. Stop at the first successful import and prefer a file + // representation over loading the entire payload into memory. + let types = provider.registeredTypeIdentifiers.compactMap { UTType($0) }.filter { + $0.conforms(to: .data) && !$0.conforms(to: .url) && !$0.conforms(to: .plainText) + } + for type in types { + try Task.checkCancellation() + do { + return try await loadFile(provider: provider, type: type, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + try Task.checkCancellation() + do { + return try await loadData(provider: provider, type: type, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + } + + try Task.checkCancellation() + if provider.canLoadObject(ofClass: UIImage.self) { + do { + return try await loadImage(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + } + try Task.checkCancellation() + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + provider.canLoadObject(ofClass: NSURL.self) { + do { + return try await loadLink(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + } + try Task.checkCancellation() + if provider.canLoadObject(ofClass: NSString.self) { + do { + return try await loadText(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + } + try Task.checkCancellation() + if provider.canLoadObject(ofClass: NSAttributedString.self) { + do { + return try await loadAttributedText(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } catch { + lastError = error + } + } + try Task.checkCancellation() + throw lastError + } + + private static func loadFile(provider: NSItemProvider, type: UTType, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let progress = provider.loadFileRepresentation(forTypeIdentifier: type.identifier) { url, error in + pending.finish { + if let error { throw error } + guard let url else { throw CocoaError(.fileReadUnknown) } + return try copy(source: url, name: name, fallbackName: fallbackName, type: type, directory: directory) + } + } + return { progress.cancel() } + } + } + + private static func loadData(provider: NSItemProvider, type: UTType, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let progress = provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, error in + pending.finish { + if let error { throw error } + guard let data else { throw CocoaError(.fileReadUnknown) } + return try write(data, name: name ?? fallbackName, type: type, directory: directory) + } + } + return { progress.cancel() } + } + } + + private static func loadImage(provider: NSItemProvider, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let progress = provider.loadObject(ofClass: UIImage.self) { object, error in + pending.finish { + if let error { throw error } + guard let image = object as? UIImage, + let data = autoreleasepool(invoking: { image.pngData() }) else { throw CocoaError(.fileReadCorruptFile) } + return try write(data, name: name ?? fallbackName, type: .png, directory: directory) + } + } + return { progress.cancel() } + } + } + + private static func loadText(provider: NSItemProvider, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let progress = provider.loadObject(ofClass: NSString.self) { object, error in + pending.finish { + if let error { throw error } + guard let text = object as? String else { throw CocoaError(.fileReadCorruptFile) } + return try write(Data(text.utf8), name: name ?? fallbackName, type: .utf8PlainText, directory: directory) + } + } + return { progress.cancel() } + } + } + + /// Last-resort text fallback for Notes-style providers. A file or data + /// representation (for example RTF) is preferred so formatting is preserved. + private static func loadAttributedText(provider: NSItemProvider, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let progress = provider.loadObject(ofClass: NSAttributedString.self) { object, error in + pending.finish { + if let error { throw error } + guard let text = object as? NSAttributedString else { throw CocoaError(.fileReadCorruptFile) } + return try write(Data(text.string.utf8), name: name ?? fallbackName, type: .utf8PlainText, directory: directory) + } + } + return { progress.cancel() } + } + } + + private static func loadLink(provider: NSItemProvider, name: String?, fallbackName: String, directory: URL) async throws -> String { + let content: LoadedURL = try await request { pending in + let progress = provider.loadObject(ofClass: NSURL.self) { object, error in + pending.finish { + if let error { throw error } + guard let url = object as? URL else { throw URLError(.badURL) } + if url.isFileURL { + // Even a provider advertising only public.url may return + // a file reference. Copy it before this callback returns. + let name = try copyReferencedFile(source: url, name: name, fallbackName: fallbackName, directory: directory) + return .file(name) + } + return .remote(url) + } + } + return { progress.cancel() } + } + switch content { + case .file(let name): + return name + case .remote(let url): + guard ["https", "http"].contains(url.scheme?.lowercased() ?? "") else { + throw URLError(.unsupportedURL) + } + // Preserve the previous behavior: a shared web link uploads the + // downloaded resource. URLSession streams it to disk, not Data. + try Task.checkCancellation() + return try await download(url: url, name: name, fallbackName: fallbackName, directory: directory) + } + } + + private static func download(url: URL, name: String?, fallbackName: String, directory: URL) async throws -> String { + try await request { pending in + let task = URLSession.shared.downloadTask(with: url) { temporaryURL, response, error in + pending.finish { + if let error { throw error } + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let temporaryURL else { throw URLError(.badServerResponse) } + let type = response.mimeType.flatMap { UTType(mimeType: $0) } + let destination = destination(name: name ?? response.suggestedFilename ?? fallbackName, type: type, directory: directory) + try FileManager.default.copyItem(at: temporaryURL, to: destination) + return destination.lastPathComponent + } + } + task.resume() + return { task.cancel() } + } + } + + nonisolated private static func write(_ data: Data, name: String, type: UTType, directory: URL) throws -> String { + let url = destination(name: name, type: type, directory: directory) + try data.write(to: url, options: .atomic) + return url.lastPathComponent + } + + nonisolated private static func copyReferencedFile(source: URL, name: String?, fallbackName: String, directory: URL) throws -> String { + let access = source.startAccessingSecurityScopedResource() + defer { if access { source.stopAccessingSecurityScopedResource() } } + var coordinationError: NSError? + var result: Result? + // Coordinate references to external documents, including File Provider + // files that may need to be materialized before they can be read. + NSFileCoordinator().coordinate(readingItemAt: source, options: [], error: &coordinationError) { readableURL in + result = Result { try copy(source: readableURL, name: name, fallbackName: fallbackName, type: nil, directory: directory) } + } + if let coordinationError { throw coordinationError } + guard let result else { throw CocoaError(.fileReadUnknown) } + return try result.get() + } + + nonisolated private static func copy(source: URL, name: String?, fallbackName: String, type: UTType?, directory: URL) throws -> String { + let access = source.startAccessingSecurityScopedResource() + defer { if access { source.stopAccessingSecurityScopedResource() } } + let values = try source.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { throw CocoaError(.featureUnsupported) } + let sourceName = source.lastPathComponent.isEmpty ? fallbackName : source.lastPathComponent + let sourceType = UTType(filenameExtension: source.pathExtension) + let contentType: UTType? + if let sourceType, sourceType.conforms(to: type ?? .data) { + contentType = sourceType + } else { + contentType = type + } + let destination = destination(name: name ?? sourceName, type: contentType, directory: directory) + do { + try FileManager.default.copyItem(at: source, to: destination) + return destination.lastPathComponent + } catch { + // A failed copy may leave a partial file behind. + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + nonisolated private static func destination(name: String, type: UTType?, directory: URL) -> URL { + // Suggested names are untrusted paths. Keep only a single filename. + var name = (name as NSString).lastPathComponent + .components(separatedBy: .controlCharacters).joined() + .replacingOccurrences(of: "\\", with: "_") + .replacingOccurrences(of: "/", with: "_") + if name.isEmpty || name == "." || name == ".." { name = "Shared item" } + // The plain-text fallback writes UTF-8 bytes even if that UTType has no filename tag. + if let type, let preferredExtension = type == .utf8PlainText ? "txt" : type.preferredFilenameExtension { + let existingExtension = (name as NSString).pathExtension + if existingExtension.isEmpty { + name += "." + preferredExtension + } else if UTType(filenameExtension: existingExtension)?.conforms(to: type) != true { + name = (name as NSString).deletingPathExtension + "." + preferredExtension + } + } + let ext = (name as NSString).pathExtension + var stem = ext.isEmpty ? name : (name as NSString).deletingPathExtension + // Leave room for an extension and collision suffix on common file systems. + while stem.utf8.count > 180 { stem.removeLast() } + let suffix = ext.isEmpty ? "" : "." + String(ext.prefix(32)) + var candidate = directory.appendingPathComponent(stem + suffix) + var counter = 1 + while FileManager.default.fileExists(atPath: candidate.path) { + candidate = directory.appendingPathComponent(stem + " (\(counter))" + suffix) + counter += 1 + } + return candidate + } +} diff --git a/Share/NCShareRecentFoldersStore.swift b/Share/NCShareRecentFoldersStore.swift new file mode 100644 index 0000000000..fc3e074cd2 --- /dev/null +++ b/Share/NCShareRecentFoldersStore.swift @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +@MainActor +struct NCShareRecentFoldersStore { + struct Item: Codable, Equatable, Sendable { + let serverUrl: String + let name: String + let ocId: String + let isEncrypted: Bool + } + + private let limit = 3 + private let defaults: UserDefaults + private let keyPrefix = "share.recentFolders.v1." + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + func folders(account: String) -> [Item] { + guard let data = defaults.data(forKey: keyPrefix + account), + let items = try? JSONDecoder().decode([Item].self, from: data) else { return [] } + var seen: Set = [] + return Array(items.filter { !$0.serverUrl.isEmpty && seen.insert($0.serverUrl).inserted }.prefix(limit)) + } + + /// Call only after the complete upload operation has succeeded. + func record(_ item: Item, account: String) { + guard !account.isEmpty, !item.serverUrl.isEmpty else { return } + let updated = [item] + folders(account: account).filter { $0.serverUrl != item.serverUrl } + save(Array(updated.prefix(limit)), account: account) + } + + func remove(serverUrl: String, account: String) { + save(folders(account: account).filter { $0.serverUrl != serverUrl }, account: account) + } + + private func save(_ items: [Item], account: String) { + guard let data = try? JSONEncoder().encode(items) else { return } + defaults.set(data, forKey: keyPrefix + account) + } +} diff --git a/Share/NCShareSessionStore.swift b/Share/NCShareSessionStore.swift new file mode 100644 index 0000000000..90547c2014 --- /dev/null +++ b/Share/NCShareSessionStore.swift @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +@MainActor +final class NCShareSessionStore { + private(set) var account: tableAccount? = NCManageDatabase.shared.getActiveTableAccount() + + /// Selection belongs to this share request; do not change the app's active account. + /// Re-read even the current account because it may have been removed by the app. + func setSessionAccount(_ account: String) async -> NCSession.Session? { + guard let selected = await NCManageDatabase.shared.getTableAccountAsync(account: account) else { return nil } + guard !Task.isCancelled else { return nil } + self.account = selected + return getSession() + } + + func getSession() -> NCSession.Session { + if let account { + return NCSession.Session(account: account.account, urlBase: account.urlBase, user: account.user, userId: account.userId) + } + return NCSession.Session(account: "", urlBase: "", user: "", userId: "") + } + +} diff --git a/Share/Share-Bridging-Header.h b/Share/Share-Bridging-Header.h index 68997c812e..fce95c266c 100644 --- a/Share/Share-Bridging-Header.h +++ b/Share/Share-Bridging-Header.h @@ -5,4 +5,3 @@ // Use this file to import your target's public headers that you would like to expose to Swift. #import "NCEndToEndEncryption.h" -#import "UIImage+animatedGIF.h" diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift new file mode 100644 index 0000000000..d701bb60fe --- /dev/null +++ b/Share/View/NCShareAccountMenu.swift @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCShareAccountMenu: View { + @ObservedObject var model: NCShareViewModel + + var body: some View { + let selected = model.accounts.first(where: \.isSelected) + Menu { + Picker(NSLocalizedString("_account_select_", comment: ""), selection: Binding( + get: { model.accounts.first(where: \.isSelected)?.id ?? "" }, + set: { model.selectAccount($0) } + )) { + ForEach(model.accounts) { account in + Label { + Text(verbatim: account.name) + Text(verbatim: account.user + " · " + account.serverUrl) + } icon: { + avatar(account.avatar) + } + .tag(account.id) + } + } + .pickerStyle(.inline) + } label: { + avatar(selected?.avatar) + .frame(minWidth: 44, minHeight: 44) + } + .accessibilityLabel(NSLocalizedString("_account_select_", comment: "")) + .accessibilityValue(Text(verbatim: selected?.name ?? "")) + .buttonStyle(.plain) + .dynamicTypeSize(.xSmall ... .accessibility2) + .disabled(model.accounts.count < 2) + + } + + @ViewBuilder + private func avatar(_ image: UIImage?) -> some View { + if let image { + Image(uiImage: image) + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .clipShape(Circle()) + } else { + Image(systemName: "person.crop.circle") + } + } +} + +#if DEBUG && SHARE_PREVIEWS +#Preview("Accounts") { + NavigationStack { + Text("Share") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + NCShareAccountMenu(model: .preview()) + } + } + } +} +#endif diff --git a/Share/View/NCShareFilenameView.swift b/Share/View/NCShareFilenameView.swift new file mode 100644 index 0000000000..781226fa82 --- /dev/null +++ b/Share/View/NCShareFilenameView.swift @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// Keep the actual extension in its own text run, as in UIKit's setBidiSafeFilename. +/// Only presentation changes; file operations always use the original name. +struct NCShareFilenameView: View { + let name: String + + var body: some View { + let filename = name as NSString + let ext = filename.pathExtension + let base = filename.deletingPathExtension + HStack(alignment: .firstTextBaseline, spacing: 0) { + if ext.isEmpty || base.isEmpty { + Text(verbatim: Self.displayName(name)) + .lineLimit(1) + .truncationMode(.middle) + } else { + Text(verbatim: Self.displayName(base)) + .lineLimit(1) + .truncationMode(.middle) + Text(verbatim: "." + Self.displayName(ext)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: Self.displayName(name))) + } + + /// Remove explicit direction controls from display text, including dialog titles. + /// Ordinary Arabic/Hebrew letters retain their natural text direction. + static func displayName(_ name: String) -> String { + String(String.UnicodeScalarView(name.unicodeScalars.filter { + switch $0.value { + case 0x061C, 0x200E...0x200F, 0x202A...0x202E, 0x2066...0x2069: + return false + default: + return true + } + })) + } +} + +#if DEBUG && SHARE_PREVIEWS +#Preview("File names and BIDI overrides") { + VStack(alignment: .leading, spacing: 16) { + NCShareFilenameView(name: "Report.pdf") + NCShareFilenameView(name: "photo\u{202E}gpj.exe") + NCShareFilenameView(name: "تقرير.pdf") + NCShareFilenameView(name: ".gitignore") + NCShareFilenameView(name: "A very long file name with a visible extension.pdf") + } + .frame(width: 240) + .padding() +} +#endif diff --git a/Share/View/NCShareUploadButton.swift b/Share/View/NCShareUploadButton.swift new file mode 100644 index 0000000000..ca82b2e1b0 --- /dev/null +++ b/Share/View/NCShareUploadButton.swift @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCShareUploadButton: View { + let fileCount: Int + var isPreparing = false + var title: String? + let action: @MainActor () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + if isPreparing { + ProgressView() + .tint(.white) + Text(NSLocalizedString("_share_conflict_checking_", comment: "")) + } else { + Text(title ?? String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), fileCount)) + } + } + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .padding(.horizontal, 16) + } +} diff --git a/Share/View/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift new file mode 100644 index 0000000000..118f3437d7 --- /dev/null +++ b/Share/View/NCShareUploadConflictView.swift @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCShareUploadConflictView: View { + let confirmation: NCShareUploadConfirmation + let resolve: @MainActor (NCShareUploadChoice?) -> Void + + var body: some View { + let renamedItems = confirmation.renamedItems + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + Text(NSLocalizedString("_share_conflict_new_names_", comment: "")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityAddTraits(.isHeader) + .padding(.horizontal, 12) + VStack(alignment: .leading, spacing: 0) { + ForEach(renamedItems, id: \.sourceName) { item in + VStack(alignment: .leading, spacing: 4) { + NCShareFilenameView(name: item.sourceName) + .cappedFont(.caption, maxDynamicType: .accessibility2) + .foregroundStyle(.secondary) + NCShareFilenameView(name: item.fileName) + } + .padding(.vertical, 12) + if item.sourceName != renamedItems.last?.sourceName { + Divider() + } + } + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + Text(confirmation.isBatch + ? NSLocalizedString("_share_conflict_batch_message_", comment: "") + : String(format: NSLocalizedString("_share_conflict_single_message_", comment: ""), NCShareFilenameView.displayName(confirmation.items[0].sourceName))) + .font(.footnote) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 16) { + if !confirmation.isBatch, confirmation.replacementFileName != nil { + Button(role: .destructive) { + resolve(.replace) + } label: { + Text(NSLocalizedString("_share_conflict_replace_", comment: "")) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .tint(.red) + .padding(.horizontal, 16) + } + + NCShareUploadButton( + fileCount: confirmation.items.count, + title: confirmation.isBatch ? nil : NSLocalizedString("_share_conflict_upload_with_new_name_", comment: "") + ) { + resolve(.keepBoth) + } + } + .padding(.top, 10) + .padding(.bottom, 16) + .background(Color(uiColor: .systemGroupedBackground)) + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(NSLocalizedString("_share_conflict_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("_cancel_", comment: "")) { resolve(nil) } + } + } + } + } +} + +#if DEBUG && SHARE_PREVIEWS +#Preview("Single file conflict") { + NCShareUploadConflictView( + confirmation: NCShareUploadConfirmation( + account: "personal", + serverUrl: "https://cloud.example.com/Documents", + items: [NCShareUploadItem(sourceName: "Report.pdf", fileName: "Report (1).pdf")], + replacementFileName: "Report.pdf", + replacementETag: "preview-etag", + requiresConfirmation: true), + resolve: { _ in }) +} + +#Preview("Multiple file conflicts") { + NCShareUploadConflictView( + confirmation: NCShareUploadConfirmation( + account: "personal", + serverUrl: "https://cloud.example.com/Documents", + items: [ + NCShareUploadItem(sourceName: "Report.pdf", fileName: "Report (1).pdf"), + NCShareUploadItem(sourceName: "Photo.jpg", fileName: "Photo (2).jpg"), + NCShareUploadItem(sourceName: "Notes.txt", fileName: "Notes.txt") + ], + replacementFileName: nil, + replacementETag: nil, + requiresConfirmation: true), + resolve: { _ in }) +} +#endif diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift new file mode 100644 index 0000000000..c53dea4305 --- /dev/null +++ b/Share/View/NCShareView.swift @@ -0,0 +1,428 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCShareView: View { + @ObservedObject var model: NCShareViewModel + @State private var fileActions: String? + + private var canShowContent: Bool { model.rootFolder != nil && model.isAuthenticated } + + private var isRefreshing: Bool { + model.isLoading || model.isSwitchingAccount || model.isLoadingAccounts || + model.isLoadingCapabilities || !model.folderTasks.isEmpty + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + statusView + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + if canShowContent { + GeometryReader { geometry in + let sideBySide = UIDevice.current.userInterfaceIdiom == .phone && geometry.size.width > geometry.size.height + VStack(spacing: 0) { + if sideBySide { + createFolderHeader + .padding(.horizontal, 16) + HStack(spacing: 10) { + folderList(showHeader: false, horizontalInset: 0) + .frame(maxWidth: .infinity) + fileList(fillsHeight: true, horizontalInset: 0) + .frame(width: min(320, geometry.size.width * 0.38)) + } + .padding(.horizontal, 16) + } else { + folderList() + } + VStack(spacing: 10) { + if !sideBySide { + fileList() + } + uploadButton + } + .padding(.top, 10) + .padding(.bottom, 16) + .background(Color(uiColor: .systemGroupedBackground)) + } + } + } else { + Spacer(minLength: 0) + } + } + .background(Color(uiColor: .systemGroupedBackground)) + .dynamicTypeSize(.xSmall ... .accessibility2) + .background(Color(uiColor: .systemBackground)) + .sheet(isPresented: $model.isShowingUploadConfirmation, onDismiss: model.uploadConfirmationDismissed) { + if let confirmation = model.pendingUploadConfirmation?.plan { + NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) + } + } + .navigationTitle(String(format: NSLocalizedString("_share_save_to_", comment: "Share title; %@ is the cloud brand name"), NCBrandOptions.shared.brand)) + .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + if canShowContent { + HStack(spacing: 8) { + NCShareAccountMenu(model: model) + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + if isRefreshing { + ProgressView() + .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) + .padding(.trailing, 8) + } + } + } + } + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("_cancel_", comment: "")) { model.cancel() } + } + } + } + } + + // Show one message at a time in the dedicated top area, in priority order. + // Folder and attachment rows remain content-only even when a request fails. + @ViewBuilder + private var statusView: some View { + if !model.alertMessage.isEmpty { + statusError(model.alertMessage) { model.acknowledgeAlert() } + } else if !canShowContent { + ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .padding() + } else if !model.inputErrorMessage.isEmpty && !model.isLoadingAccounts { + statusError(model.inputErrorMessage) { model.acknowledgeInputError() } + } else if model.isOffline { + VStack(spacing: 8) { + Label(NSLocalizedString("_share_online_required_", comment: ""), systemImage: "wifi.slash") + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .multilineTextAlignment(.center) + Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryConnection) + } + .padding() + } else if !model.capabilitiesErrorMessage.isEmpty { + VStack(spacing: 8) { + Text(verbatim: model.capabilitiesErrorMessage) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryCapabilities) + .disabled(model.isSwitchingAccount) + } + .padding() + } else if let row = model.visibleFolderRows.first(where: { model.folderLoadErrors[$0.folder.serverUrl] != nil }), + let error = model.folderLoadErrors[row.folder.serverUrl] { + VStack(spacing: 8) { + Text(verbatim: error) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .multilineTextAlignment(.center) + Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } + } + .padding() + + } else if model.isPreparingInput { + ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) + .padding() + } + } + + private func statusError(_ message: String, acknowledge: @escaping () -> Void) -> some View { + VStack(spacing: 8) { + Text(verbatim: message) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .multilineTextAlignment(.center) + Button(NSLocalizedString("_ok_", comment: ""), action: acknowledge) + } + .padding() + } + + private func folderList(showHeader: Bool = true, horizontalInset: CGFloat = 16) -> some View { + VStack(spacing: 0) { + if showHeader { + createFolderHeader + .padding(.horizontal, horizontalInset) + } + ScrollViewReader { proxy in + List { + Section { + if !model.recentFolders.isEmpty { + folderListTitle("_share_recent_folders_") + ForEach(model.recentFolders) { folder in + folderRow(folder, depth: nil) + } + } + folderListTitle("_share_all_folders_") + ForEach(model.visibleFolderRows) { row in + folderRow(row.folder, depth: row.depth) + .id(row.folder.serverUrl) + } + } + } + .listStyle(.plain) + .contentMargins(.vertical, 0, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 0) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, horizontalInset) + .onChange(of: model.folderScrollTarget) { _, target in + guard let target else { return } + withAnimation { proxy.scrollTo(target, anchor: .center) } + model.folderScrollTarget = nil + } + } + } + .frame(maxHeight: .infinity) + .clipped() + .background(Color(uiColor: .systemGroupedBackground)) + .opacity(isRefreshing ? 0.45 : 1) + .disabled(isRefreshing || model.isPreparingUpload || !model.isAccountReady) + } + + private func folderListTitle(_ key: String) -> some View { + Text(NSLocalizedString(key, comment: "")) + .cappedFont(.caption, maxDynamicType: .accessibility2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + .padding(.bottom, 2) + .accessibilityAddTraits(.isHeader) + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 12)) + .listRowSeparator(.hidden) + .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) + } + + private var createFolderHeader: some View { + HStack { + Spacer() + Button(action: model.createFolder) { + HStack(spacing: 8) { + Text(NSLocalizedString("_create_folder_", comment: "")) + Image(systemName: "folder.badge.plus") + } + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .frame(minHeight: 50) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .tint(.blue) + .disabled(!model.canUseUploadDestination) + } + .textCase(nil) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + + private func folderRow(_ folder: NCShareFolderItem, depth: Int?) -> some View { + let isSelected = model.selectedFolder?.serverUrl == folder.serverUrl && model.isRecentFolderSelected == (depth == nil) + return HStack(spacing: 4) { + if let depth { + Button { + model.toggleFolder(folder) + } label: { + Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") + .frame(width: 50, height: 50) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityLabel(String(format: NSLocalizedString( + model.expandedFolderURLs.contains(folder.serverUrl) ? "_share_collapse_folder_" : "_share_expand_folder_", comment: ""), folder.name)) + .padding(.leading, CGFloat(min(depth, 8)) * 16) + } else { + Image(systemName: "clock") + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .foregroundStyle(.secondary) + .frame(width: 50, height: 50) + .accessibilityHidden(true) + } + Button { + model.selectFolder(folder, fromRecents: depth == nil) + } label: { + HStack(spacing: 8) { + if let image = folder.image { + Image(uiImage: image) + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 26, height: 26) + .overlay(alignment: .topTrailing) { + if folder.isFavorite { + Image(systemName: "star.fill") + .resizable() + .scaledToFit() + .foregroundStyle(Color(uiColor: NCBrandColor.shared.yellowFavorite)) + .frame(width: 10, height: 10) + .background { + Image(systemName: "star") + .resizable() + .scaledToFit() + .fontWeight(.thin) + .foregroundStyle(Color(uiColor: .systemBackground)) + .frame(width: 12, height: 12) + } + .offset(x: 3, y: -3) + .accessibilityLabel(NSLocalizedString("_share_favorite_folder_", comment: "")) + } + } + } + VStack(alignment: .leading, spacing: 1) { + Text(verbatim: folder.name) + .cappedFont(.body, maxDynamicType: .accessibility2) + .foregroundStyle(Color(uiColor: .label)) + .lineLimit(1) + if depth == nil { + Text(verbatim: folder.path) + .cappedFont(.caption2, maxDynamicType: .accessibility2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer(minLength: 4) + if isSelected { + Image(systemName: "checkmark") + .frame(width: 50, height: 50) + .accessibilityHidden(true) + } + } + .frame(minHeight: 50) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.1)) + .padding(.horizontal, 5) + } + } + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2)) + .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) + } + + private func fileList(fillsHeight: Bool = false, horizontalInset: CGFloat = 16) -> some View { + List(model.fileNames, id: \.self) { fileName in + Button { + fileActions = fileName + } label: { + HStack { + Label { + NCShareFilenameView(name: fileName) + } icon: { + Group { + if let thumbnail = model.fileThumbnails[fileName] { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } else { + Image(systemName: model.fileIcon(named: fileName)) + } + } + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .accessibilityHidden(true) + .task(id: fileName) { model.loadThumbnail(named: fileName) } + } + .foregroundStyle(.primary) + Spacer(minLength: 0) + Image(systemName: "ellipsis") + .foregroundStyle(Color.accentColor) + .frame(width: 50, height: 50) + .accessibilityHidden(true) + } + .cappedFont(.body, maxDynamicType: .accessibility2) + .frame(maxWidth: .infinity, minHeight: 50) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .confirmationDialog(Text(verbatim: NCShareFilenameView.displayName(fileName)), isPresented: Binding( + get: { fileActions == fileName }, + set: { if !$0 { fileActions = nil } } + ), titleVisibility: .visible) { + Button(NSLocalizedString("_rename_file_", comment: "")) { + model.showRenameFileDialog(named: fileName) + } + Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { + model.removeFile(named: fileName) + } + Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) { } + } + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) + .listRowSeparator(.hidden) + .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) + } + .listStyle(.plain) + .contentMargins(.vertical, 0, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 50) + .frame(height: fillsHeight ? nil : CGFloat(min(max(model.fileNames.count, 1), 3)) * 50) + .frame(maxHeight: fillsHeight ? .infinity : nil) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, horizontalInset) + .opacity(isRefreshing ? 0.45 : 1) + .disabled(isRefreshing || model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || !model.inputErrorMessage.isEmpty) + } + + private var uploadButton: some View { + NCShareUploadButton(fileCount: model.fileNames.count, isPreparing: model.isPreparingUpload, action: model.upload) + .disabled(!model.canUpload) + } + +} + +#if DEBUG && SHARE_PREVIEWS +#Preview("Share") { + NCShareView(model: .preview()) + .environment(\.locale, Locale(identifier: "en_US")) + .allowsHitTesting(false) +} + +#Preview("No folders") { + let model = NCShareViewModel.preview() + model.folderChildren = [:] + model.recentFolders = [] + if let rootFolder = model.rootFolder { + model.loadedFolderURLs = [rootFolder.serverUrl] + } + return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) + .allowsHitTesting(false) +} + +#Preview("Preparing files") { + let model = NCShareViewModel.preview() + model.fileNames = [] + model.isPreparingInput = true + return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) + .allowsHitTesting(false) +} +#Preview("Capabilities unavailable") { + let model = NCShareViewModel.preview() + model.serverCapabilities = nil + model.capabilitiesErrorMessage = NSLocalizedString("_share_capabilities_unavailable_", comment: "") + return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) + .allowsHitTesting(false) +} + +#Preview("Loading capabilities") { + let model = NCShareViewModel.preview() + model.serverCapabilities = nil + model.isLoadingCapabilities = true + return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) + .allowsHitTesting(false) +} +#endif diff --git a/Tests/NextcloudUITests/BaseUIXCTestCase.swift b/Tests/NextcloudUITests/BaseUIXCTestCase.swift index 734d3f0b82..d718cb5d71 100644 --- a/Tests/NextcloudUITests/BaseUIXCTestCase.swift +++ b/Tests/NextcloudUITests/BaseUIXCTestCase.swift @@ -59,8 +59,8 @@ class BaseUIXCTestCase: XCTestCase { try await Task.sleep(for: .seconds(TestConstants.controlExistenceTimeout)) } - /// Sign in through the app's authentication flow, supporting system authentication and the embedded fallback. - func logIn() async throws { + /// Sign in through the browser by default, or let the app provision a test account on the login screen. + func logIn(usingAppConfig: Bool = false) async throws { let login = app.buttons["login"] let deadline = Date().addingTimeInterval(30) // Startup can restore the main interface after the first accessibility snapshot. @@ -79,7 +79,19 @@ class BaseUIXCTestCase: XCTestCase { throw UITestError.waitForExistence(login) } login.tap() + if !usingAppConfig { + try await authenticateInBrowser() + } + + let sidebar = app.buttons["openSidebar"].firstMatch + let loginTimeout: TimeInterval = usingAppConfig ? 90 : 30 + guard sidebar.await(timeout: loginTimeout) || app.buttons["accountSwitcher"].exists else { + attachAuthenticationDiagnostics() + throw UITestError.waitForExistence(sidebar) + } + } + private func authenticateInBrowser() async throws { let serverAddress = app.textFields["serverAddress"].firstMatch guard serverAddress.await() else { throw UITestError.waitForExistence(serverAddress) } serverAddress.tap() @@ -108,12 +120,6 @@ class BaseUIXCTestCase: XCTestCase { attachAuthenticationDiagnostics() throw UITestError.waitForExistence(grant) } - - let sidebar = app.buttons["openSidebar"].firstMatch - guard sidebar.await(timeout: 30) || app.buttons["accountSwitcher"].exists else { - attachAuthenticationDiagnostics() - throw UITestError.waitForExistence(sidebar) - } } /// Waiting alone does not trigger interruption monitors; dismiss password prompts in their owning process. diff --git a/Tests/NextcloudUITests/PasscodeUITests.swift b/Tests/NextcloudUITests/PasscodeUITests.swift index 1e29c63d4b..f752c9a4af 100644 --- a/Tests/NextcloudUITests/PasscodeUITests.swift +++ b/Tests/NextcloudUITests/PasscodeUITests.swift @@ -27,11 +27,14 @@ final class PasscodeUITests: BaseUIXCTestCase { addUIInterruptionMonitor(withDescription: "Allow Notifications", for: "Allow") app = XCUIApplication() app.launchArguments = ["UI_TESTING", "UI_TESTING_PASSCODE", "UI_TESTING_NO_EXIT"] + languageArguments + app.launchEnvironment["UI_TESTING_PASSCODE_SERVER"] = TestConstants.server + app.launchEnvironment["UI_TESTING_PASSCODE_USERNAME"] = TestConstants.username + app.launchEnvironment["UI_TESTING_PASSCODE_PASSWORD"] = TestConstants.password if name.contains("testAppResetAfterTenFailuresAcrossLockouts") { app.launchEnvironment["UI_TESTING_PASSCODE_LOCKOUT_SECONDS"] = String(resetTestLockoutSeconds) } app.launch() - try await logIn() + try await logIn(usingAppConfig: true) openSettings() XCTAssertFalse(changeSetting.exists, "Each test must start with app lock disabled.") } diff --git a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift new file mode 100644 index 0000000000..aea5dfcd33 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit +import Testing +import UniformTypeIdentifiers +@testable import Nextcloud + +// The Share importer source is included directly in this test target because +// an application extension cannot be imported as a framework by the app tests. +@MainActor +@Suite("Share extension input preparation") +struct NCShareLoaderItemProviderTests { + @Test("Repeated names retain every attachment and its source order") + func duplicateNames() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let payloads = [Data("first".utf8), Data("second".utf8), Data("third".utf8)] + let providers = payloads.map { provider(data: $0, type: .pdf, name: "report.pdf") } + + let output = try await load(providers, directory: directory) + + #expect(output.failures.isEmpty) + #expect(output.fileNames.count == 3) + #expect(Set(output.fileNames).count == 3) + let imported = try output.fileNames.map { try Data(contentsOf: directory.appendingPathComponent($0)) } + #expect(imported == payloads) + } + + @Test("JPEG bytes and extension are preserved without PNG conversion") + func originalImageFormat() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let jpeg = Data([0xff, 0xd8, 0xff, 0xd9]) + + let output = try await load([provider(data: jpeg, type: .jpeg, name: "photo.jpg")], directory: directory) + + let name = try #require(output.fileNames.first) + #expect(output.failures.isEmpty) + #expect(UTType(filenameExtension: (name as NSString).pathExtension) == .jpeg) + #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == jpeg) + } + + @Test("A provider with multiple formats imports only its preferred representation") + func multipleRepresentations() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let item = provider(data: Data("preferred".utf8), type: .png, name: "image.png") + item.registerDataRepresentation(forTypeIdentifier: UTType.jpeg.identifier, visibility: .all) { completion in + completion(Data("alternative".utf8), nil) + return nil + } + + let output = try await load([item], directory: directory) + + #expect(output.fileNames.count == 1) + let name = try #require(output.fileNames.first) + #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == Data("preferred".utf8)) + } + + @Test("Shared text is saved as UTF-8 without altering its content") + func unicodeText() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let text = "Caffè ☕️\nSeconda riga" + let item = NSItemProvider(object: text as NSString) + + let output = try await load([item], directory: directory) + + #expect(output.failures.isEmpty) + let name = try #require(output.fileNames.first) + #expect((name as NSString).pathExtension == "txt") + #expect(try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) == text) + } + + @Test("Failed image and URL representations do not discard usable text") + func failedRepresentationsFallBackToText() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let text = "A note that must not be discarded" + let item = NSItemProvider(object: text as NSString) + for type in [UTType.png, UTType.url] { + item.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in + completion(nil, CocoaError(.fileReadCorruptFile)) + return nil + } + } + + let output = try await load([item], directory: directory) + + #expect(output.failures.isEmpty) + #expect(output.fileNames.count == 1) + let name = try #require(output.fileNames.first) + #expect(try String(contentsOf: directory.appendingPathComponent(name), encoding: .utf8) == text) + } + + @Test("Attributed text is imported once, retaining a rich representation when available") + func attributedText() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let text = "A note with formatting\nCaffè ☕️" + let attributed = NSAttributedString(string: text, attributes: [.font: UIFont.boldSystemFont(ofSize: 17)]) + let rtf = try attributed.data(from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]) + let item = provider(data: rtf, type: .rtf, name: "Note") + item.registerObject(text as NSString, visibility: .all) + + let output = try await load([item], directory: directory) + + #expect(output.failures.isEmpty) + #expect(output.fileNames.count == 1) + let name = try #require(output.fileNames.first) + let file = directory.appendingPathComponent(name) + #expect(file.pathExtension == "rtf") + #expect(try Data(contentsOf: file) == rtf) + } + + @Test("An empty file is valid input") + func emptyFile() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let output = try await load([provider(data: Data(), type: .pdf, name: "empty.pdf")], directory: directory) + + let name = try #require(output.fileNames.first) + #expect(output.failures.isEmpty) + #expect(try Data(contentsOf: directory.appendingPathComponent(name)).isEmpty) + } + + @Test("Failures are reported alongside successfully imported attachments") + func partialFailure() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let broken = NSItemProvider() + broken.registerDataRepresentation(forTypeIdentifier: UTType.pdf.identifier, visibility: .all) { completion in + completion(nil, CocoaError(.fileReadNoPermission)) + return nil + } + + let output = try await load([broken, provider(data: Data("ok".utf8), type: .pdf, name: "ok.pdf")], directory: directory) + + #expect(output.failures.count == 1) + #expect(output.fileNames.count == 1) + let name = try #require(output.fileNames.first) + #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == Data("ok".utf8)) + } + + @Test("Suggested paths cannot escape the request directory or replace unrelated files") + func pathConfinement() async throws { + let root = temporaryDirectory() + let directory = root.appendingPathComponent("request", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let outside = root.appendingPathComponent("outside.pdf") + let original = Data("keep me".utf8) + try original.write(to: outside) + + let output = try await load([provider(data: Data("new".utf8), type: .pdf, name: "../outside.pdf")], directory: directory) + + let name = try #require(output.fileNames.first) + #expect(!name.contains("/")) + #expect(directory.appendingPathComponent(name).deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL) + #expect(try Data(contentsOf: outside) == original) + } + + @Test("A file URL is copied while the original remains untouched") + func fileURL() async throws { + let root = temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("original.pdf") + let bytes = Data("original contents".utf8) + try bytes.write(to: source) + let directory = root.appendingPathComponent("request", isDirectory: true) + let item = NSItemProvider(object: source as NSURL) + + let output = try await load([item], directory: directory) + + let name = try #require(output.fileNames.first) + #expect(output.failures.isEmpty) + #expect(try Data(contentsOf: source) == bytes) + #expect(try Data(contentsOf: directory.appendingPathComponent(name)) == bytes) + } + + @Test("Unsupported and missing attachments complete with an explicit result") + func unsupportedInput() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let unsupported = try await load([NSItemProvider()], directory: directory) + #expect(unsupported.fileNames.isEmpty) + #expect(unsupported.failures.count == 1) + let empty = try await load([], directory: directory) + #expect(empty.fileNames.isEmpty) + #expect(empty.failures.isEmpty) + } + + @Test("Cancellation stops a provider even when it never calls its completion", .timeLimit(.minutes(1))) + func cancelledProvider() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stalled = NSItemProvider() + let (stream, started) = AsyncStream.makeStream() + stalled.registerDataRepresentation(forTypeIdentifier: UTType.pdf.identifier, visibility: .all) { _ in + started.yield(()) + started.finish() + return Progress(totalUnitCount: 1) + } + let task = Task { try await load([stalled], directory: directory) } + for await _ in stream { break } + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation") + } catch is CancellationError { + #expect(!FileManager.default.fileExists(atPath: directory.path)) + } + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent("ShareInputTests-" + UUID().uuidString, isDirectory: true) + } + + private func provider(data: Data, type: UTType, name: String) -> NSItemProvider { + let item = NSItemProvider() + item.suggestedName = name + item.registerDataRepresentation(forTypeIdentifier: type.identifier, visibility: .all) { completion in + completion(data, nil) + return nil + } + return item + } + + private func load(_ providers: [NSItemProvider], directory: URL) async throws -> NCShareLoaderItemProvider.Output { + let item = NSExtensionItem() + item.attachments = providers + return try await NCShareLoaderItemProvider.load(items: [item], directory: directory) + } +} diff --git a/Tests/NextcloudUnitTests/NCShareRecentFoldersStoreTests.swift b/Tests/NextcloudUnitTests/NCShareRecentFoldersStoreTests.swift new file mode 100644 index 0000000000..956302d04e --- /dev/null +++ b/Tests/NextcloudUnitTests/NCShareRecentFoldersStoreTests.swift @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import Nextcloud + +@MainActor +@Suite("Share recent destinations") +struct NCShareRecentFoldersStoreTests { + @Test("Only the last three destinations are retained, newest first") + func threeRecentDestinations() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = NCShareRecentFoldersStore(defaults: defaults) + for number in 1...7 { store.record(item(number), account: "personal") } + + #expect(store.folders(account: "personal") == [7, 6, 5].map(item)) + } + + @Test("Reusing a destination moves it to the top without duplicating it") + func movesExistingDestination() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = NCShareRecentFoldersStore(defaults: defaults) + for number in 1...3 { store.record(item(number), account: "personal") } + store.record(item(2), account: "personal") + + #expect(store.folders(account: "personal") == [2, 3, 1].map(item)) + } + + @Test("Accounts have independent histories") + func separatesAccounts() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = NCShareRecentFoldersStore(defaults: defaults) + store.record(item(1), account: "personal") + store.record(item(2), account: "work") + + #expect(store.folders(account: "personal") == [item(1)]) + #expect(store.folders(account: "work") == [item(2)]) + #expect(store.folders(account: "missing").isEmpty) + } + + @Test("The history survives recreating the store") + func persistsDestinations() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + NCShareRecentFoldersStore(defaults: defaults).record(item(1), account: "personal") + + let restored = NCShareRecentFoldersStore(defaults: defaults) + #expect(restored.folders(account: "personal") == [item(1)]) + } + + @Test("A stale folder is removed only from the requested account") + func removesUnavailableDestination() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = NCShareRecentFoldersStore(defaults: defaults) + store.record(item(1), account: "personal") + store.record(item(2), account: "personal") + store.record(item(1), account: "work") + store.remove(serverUrl: item(1).serverUrl, account: "personal") + + #expect(store.folders(account: "personal") == [item(2)]) + #expect(store.folders(account: "work") == [item(1)]) + } + + @Test("Unreadable saved data does not prevent a new destination being recorded") + func corruptHistory() throws { + let suite = "ShareRecentFoldersTests-" + UUID().uuidString + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(Data("invalid".utf8), forKey: "share.recentFolders.v1.personal") + let store = NCShareRecentFoldersStore(defaults: defaults) + #expect(store.folders(account: "personal").isEmpty) + store.record(item(1), account: "personal") + #expect(store.folders(account: "personal") == [item(1)]) + } + + private func item(_ number: Int) -> NCShareRecentFoldersStore.Item { + .init(serverUrl: "https://cloud.example.com/files/folder\(number)", name: "Folder \(number)", ocId: "folder\(number)", isEncrypted: false) + } +} diff --git a/Tests/NextcloudUnitTests/NCShareUploadNamePlannerTests.swift b/Tests/NextcloudUnitTests/NCShareUploadNamePlannerTests.swift new file mode 100644 index 0000000000..ed42a58f63 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCShareUploadNamePlannerTests.swift @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Testing +@testable import Nextcloud + +// The extension's pure name planner is included directly in this test target. +@Suite("Share extension conflict names") +struct NCShareUploadNamePlannerTests { + @Test("Unused names remain unchanged") + func unchangedNames() { + let names = ["photo.jpg", "report.pdf"] + #expect(NCShareUploadNamePlanner.fileNames(for: names, existingNames: []) == names) + } + + @Test("Existing files keep their names and copies receive the first free suffix") + func existingCopies() { + #expect(NCShareUploadNamePlanner.fileNames( + for: ["photo.jpg"], existingNames: ["photo.jpg", "photo (1).jpg", "photo (2).jpg"] + ) == ["photo (3).jpg"]) + } + + @Test("An earlier conflict cannot consume the original name of a later attachment") + func reservesBatchNames() { + #expect(NCShareUploadNamePlanner.fileNames( + for: ["photo.jpg", "photo (1).jpg"], existingNames: ["photo.jpg"] + ) == ["photo (2).jpg", "photo (1).jpg"]) + } + + @Test("Different source names that become equal after validation remain distinct") + func duplicateValidatedNames() { + let names = NCShareUploadNamePlanner.fileNames(for: ["report.pdf", "report.pdf", "report.pdf"], existingNames: []) + #expect(names == ["report.pdf", "report (1).pdf", "report (2).pdf"]) + } + + @Test("An existing numeric suffix advances without nesting another suffix") + func existingSuffix() { + #expect(NCShareUploadNamePlanner.fileNames( + for: ["photo (3).JPG"], existingNames: ["photo (3).JPG", "photo (4).JPG"] + ) == ["photo (5).JPG"]) + } + + @Test("Files without extensions and hidden names remain valid") + func extensionlessAndHidden() { + #expect(NCShareUploadNamePlanner.fileNames( + for: ["README", ".env"], existingNames: ["README", ".env"] + ) == ["README (1)", ".env (1)"]) + } + + @Test("Canonical Unicode spellings refer to the same occupied name") + func unicodeNames() { + let names = NCShareUploadNamePlanner.fileNames(for: ["café.txt"], existingNames: ["cafe\u{301}.txt"]) + #expect(names == ["café (1).txt"]) + } + + @Test("A hundred conflicts produce a hundred distinct new names") + func largeBatch() { + let names = (1...100).map { "photo\($0).jpg" } + let existing = Set(names + names.map { ($0 as NSString).deletingPathExtension + " (1).jpg" }) + let allocated = NCShareUploadNamePlanner.fileNames(for: names, existingNames: existing) + + #expect(allocated.count == 100) + #expect(Set(allocated).count == 100) + #expect(Set(allocated).isDisjoint(with: existing)) + #expect(allocated == (1...100).map { "photo\($0) (2).jpg" }) + } +} diff --git a/iOSClient/Extensions/UIAlertController+Extension.swift b/iOSClient/Extensions/UIAlertController+Extension.swift index f4a2773c6a..389b38f33b 100644 --- a/iOSClient/Extensions/UIAlertController+Extension.swift +++ b/iOSClient/Extensions/UIAlertController+Extension.swift @@ -22,8 +22,8 @@ extension UIAlertController { let alertController = UIAlertController(title: NSLocalizedString("_create_folder_", comment: ""), message: nil, preferredStyle: .alert) let isDirectoryEncrypted = NCUtilityFileSystem().isDirectoryE2EE(serverUrl: serverUrl, urlBase: session.urlBase, userId: session.userId, account: session.account) - let okAction = UIAlertAction(title: NSLocalizedString("_save_", comment: ""), style: .default, handler: { _ in - guard let fileNameFolder = alertController.textFields?.first?.text else { return } + let okAction = UIAlertAction(title: NSLocalizedString("_save_", comment: ""), style: .default, handler: { [weak alertController] _ in + guard let fileNameFolder = alertController?.textFields?.first?.text else { return } if markE2ee { if NCNetworking.shared.isOffline { @@ -115,11 +115,11 @@ extension UIAlertController { textField.autocapitalizationType = .sentences } - NotificationCenter.default.addObserver( - forName: UITextField.textDidChangeNotification, - object: alertController.textFields?.first, - queue: .main) { _ in - guard let text = alertController.textFields?.first?.text else { + // The field owns this handler, so validation ends with the dialog's lifetime. + // Weak captures avoid retaining the alert through its field and actions. + alertController.textFields?.first?.addAction(UIAction { [weak alertController, weak okAction] _ in + guard let alertController, let okAction, + let text = alertController.textFields?.first?.text else { return } let folderName = text.trimmingCharacters(in: .whitespaces) @@ -147,7 +147,7 @@ extension UIAlertController { ]) alertController.setValue(attributedString, forKey: "attributedMessage") - } + }, for: .editingChanged) alertController.addAction(cancelAction) alertController.addAction(okAction) diff --git a/iOSClient/Login/NCLogin.swift b/iOSClient/Login/NCLogin.swift index bc03980e3c..86b2053048 100644 --- a/iOSClient/Login/NCLogin.swift +++ b/iOSClient/Login/NCLogin.swift @@ -214,6 +214,18 @@ class NCLogin: UIViewController, UITextFieldDelegate, NCLoginQRCodeDelegate { } private func handleLoginWithAppConfig() { +#if DEBUG + // Passcode UI tests provision an account through the same API as AppConfig. + let process = ProcessInfo.processInfo + let environment = process.environment + if process.arguments.contains("UI_TESTING"), process.arguments.contains("UI_TESTING_PASSCODE"), + let server = environment["UI_TESTING_PASSCODE_SERVER"], + let username = environment["UI_TESTING_PASSCODE_USERNAME"], + let password = environment["UI_TESTING_PASSCODE_PASSWORD"] { + Task { await getAppPassword(urlBase: server, user: username, password: password) } + return + } +#endif let accountCount = NCManageDatabase.shared.getAccounts()?.count ?? 0 // load AppConfig diff --git a/iOSClient/Networking/NCNetworking+Upload.swift b/iOSClient/Networking/NCNetworking+Upload.swift index 7a2810aabd..49fa61b4c0 100644 --- a/iOSClient/Networking/NCNetworking+Upload.swift +++ b/iOSClient/Networking/NCNetworking+Upload.swift @@ -74,6 +74,8 @@ extension NCNetworking { func uploadChunkFile(metadata: tableMetadata, performPostProcessing: Bool = true, customHeaders: [String: String]? = nil, + overwrite: Bool = true, + ifMatch: String? = nil, backgroundContext: ChunkUploadBackgroundContext? = nil, chunkProgressHandler: @escaping (_ total: Int, _ counter: Int) -> Void = { _, _ in }, uploadStart: @escaping (_ filesChunk: [(fileName: String, size: Int64)]) -> Void = { _ in }, @@ -101,7 +103,9 @@ extension NCNetworking { filesChunk: filesChunk, chunkSize: self.global.chunkSize, account: metadata.account, - options: options) { total, counter in + options: options, + ifMatch: ifMatch, + overwrite: overwrite) { total, counter in chunkProgressHandler(total, counter) } uploadStart: { filesChunk in Task { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 38914c5899..d25674f40b 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -878,6 +878,44 @@ "_passcode_incorrect_" = "Incorrect passcode. Try again."; "_passcode_digits_entered_" = "Passcode digits entered"; +/* Share extension input preparation */ +"_share_import_preparing_" = "Preparing shared files…"; +"_share_import_failed_" = "Some shared items could not be prepared. Review the files before uploading."; +"_share_import_no_files_" = "The shared items could not be prepared."; +"_share_import_item_error_" = "Item %d: %@"; +"_share_import_copy_failed_" = "A shared file could not be prepared for upload. Please try again."; + +/* Share extension upload conflicts */ +"_share_conflict_title_" = "Files already exist"; +"_share_conflict_single_message_" = "An item named \"%@\" already exists in this folder."; +"_share_conflict_batch_message_" = "Some names are already in use. These files will be uploaded with new names, keeping the existing files."; +"_share_conflict_new_names_" = "New file names"; +"_share_conflict_replace_" = "Replace existing file"; +"_share_conflict_upload_with_new_name_" = "Upload with a new name"; +"_share_conflict_keep_both_" = "Keep both"; +"_share_conflict_checking_" = "Preparing upload…"; +"_share_conflict_read_failed_" = "The destination folder could not be checked. Please try again."; + +/* Share extension destination browser */ +"_share_save_to_" = "Save to %@"; +"_share_home_folder_" = "My files"; +"_share_recent_folders_" = "Recent"; +"_share_recent_folders_empty_" = "Your last three upload destinations will appear here."; +"_share_all_folders_" = "All folders"; +"_share_expand_folder_" = "Expand %@"; +"_share_collapse_folder_" = "Collapse %@"; +"_share_retry_folder_" = "Retry loading folders"; +"_share_folder_unavailable_" = "This folder is no longer available."; + +/* Share extension capabilities */ +"_share_capabilities_loading_" = "Loading server capabilities…"; +"_share_capabilities_unavailable_" = "Server capabilities could not be loaded. Please try again before uploading, renaming files, or creating folders."; +"_share_capabilities_retry_" = "Try again"; +"_share_online_required_" = "An internet connection is required to upload files. Reconnect to continue."; +"_share_favorite_folder_" = "Favorite folder"; +"_share_checking_folder_" = "Checking folder…"; +"_share_loading_folders_" = "Loading folders…"; + "_background_upload_job_missing_" = "Upload failed. Please retry."; // MARK: Error messages diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict index 034f6cd7aa..767f642f68 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict +++ b/iOSClient/Supporting Files/en.lproj/Localizable.stringsdict @@ -2,6 +2,22 @@ + _share_upload_to_ + + NSStringLocalizedFormatKey + %#@files@ + files + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + Upload %ld file + other + Upload %ld files + + _remaining_share_downloads_ NSStringLocalizedFormatKey diff --git a/iOSClient/Utility/Passcode/NCPasscodeModel.swift b/iOSClient/Utility/Passcode/NCPasscodeModel.swift index 9c2a982fe7..883fa1fd72 100644 --- a/iOSClient/Utility/Passcode/NCPasscodeModel.swift +++ b/iOSClient/Utility/Passcode/NCPasscodeModel.swift @@ -85,12 +85,16 @@ final class NCPasscodeModel: ObservableObject { var biometricSymbol: String? { if isPreview { return step == .authenticate ? "faceid" : nil } - guard step == .authenticate, mode != .change, preferences.touchFaceID, preferences.passcode != nil else { return nil } + guard isBiometricAuthenticationEnabled else { return nil } let context = LAContext() guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return nil } return context.biometryType == .faceID ? "faceid" : "touchid" } + private var isBiometricAuthenticationEnabled: Bool { + step == .authenticate && mode != .change && preferences.touchFaceID && preferences.passcode != nil + } + var canEnterCode: Bool { result == nil && lockoutEnd == nil && !isAuthenticating } @@ -160,8 +164,9 @@ final class NCPasscodeModel: ObservableObject { func authenticateWithBiometrics() async { guard !isPreview else { return } refreshProtection() - guard canEnterCode, biometricSymbol != nil else { return } + guard canEnterCode, isBiometricAuthenticationEnabled else { return } let context = LAContext() + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) else { return } let request = UUID() biometricRequest = request biometricContext = context diff --git a/iOSClient/Utility/Passcode/NCPasscodeView.swift b/iOSClient/Utility/Passcode/NCPasscodeView.swift index 5a7f8567fa..96816b4754 100644 --- a/iOSClient/Utility/Passcode/NCPasscodeView.swift +++ b/iOSClient/Utility/Passcode/NCPasscodeView.swift @@ -38,6 +38,29 @@ struct NCPasscodeView: View { } let model = NCPasscodeModel(mode: mode, allowCancel: allowCancel) let controller = UIHostingController(rootView: NCPasscodeView(model: model, windowScene: top.view.window?.windowScene)) +#if EXTENSION_SHARE + let definedPresentationContext = top.definesPresentationContext + top.definesPresentationContext = true + controller.modalPresentationStyle = .overCurrentContext + controller.modalTransitionStyle = .crossDissolve + controller.view.accessibilityViewIsModal = true + controller.isModalInPresentation = true + model.onCompletion = { [weak controller, weak top] result in + // Keep the keypad visible while the extension closes on cancellation. + guard result == .authenticated, let controller else { + completion(result) + return + } + controller.dismiss(animated: false) { + top?.definesPresentationContext = definedPresentationContext + completion(result) + } + } + // Start once UIKit has finished presenting the keypad inside the Share panel. + top.present(controller, animated: true) { + Task { await model.start() } + } +#else controller.modalPresentationStyle = allowCancel ? .pageSheet : .fullScreen controller.isModalInPresentation = !allowCancel model.onCompletion = { [weak controller] result in @@ -51,42 +74,7 @@ struct NCPasscodeView: View { } } top.present(controller, animated: allowCancel) - } - - /// Covers an extension's existing panel without presenting another modal or changing its size. - static func embed(in container: UIViewController, - completion: @escaping @MainActor (NCPasscodeModel.Result) -> Void) { - let navigationController = (container as? UINavigationController) ?? container.navigationController - let wasNavigationBarHidden = navigationController?.isNavigationBarHidden ?? true - navigationController?.setNavigationBarHidden(true, animated: false) - let model = NCPasscodeModel(mode: .authenticate, allowCancel: true) - let controller = UIHostingController(rootView: NCPasscodeView(model: model, - windowScene: container.view.window?.windowScene)) - model.onCompletion = { [weak controller, weak navigationController] result in - guard let controller else { return } - // Keep protected content covered while the caller closes the extension on cancellation. - guard result == .authenticated else { - completion(result) - return - } - controller.willMove(toParent: nil) - controller.view.removeFromSuperview() - controller.removeFromParent() - navigationController?.setNavigationBarHidden(wasNavigationBarHidden, animated: false) - completion(result) - } - container.addChild(controller) - controller.view.backgroundColor = .systemBackground - controller.view.accessibilityViewIsModal = true - controller.view.translatesAutoresizingMaskIntoConstraints = false - container.view.addSubview(controller.view) - NSLayoutConstraint.activate([ - controller.view.leadingAnchor.constraint(equalTo: container.view.leadingAnchor), - controller.view.trailingAnchor.constraint(equalTo: container.view.trailingAnchor), - controller.view.topAnchor.constraint(equalTo: container.view.topAnchor), - controller.view.bottomAnchor.constraint(equalTo: container.view.bottomAnchor) - ]) - controller.didMove(toParent: container) +#endif } var body: some View { @@ -161,11 +149,15 @@ struct NCPasscodeView: View { .interactiveDismissDisabled(!model.allowCancel) .sensoryFeedback(.error, trigger: model.errorFeedback) .task { +#if EXTENSION_SHARE + model.refreshProtection() +#else if let windowScene, windowScene.activationState != .foregroundActive { model.refreshProtection() } else { await model.start() } +#endif } .task(id: model.lockoutEnd) { guard let deadline = model.lockoutEnd else { return } @@ -175,10 +167,12 @@ struct NCPasscodeView: View { model.refreshProtection() } catch { return } } +#if !EXTENSION_SHARE .onReceive(NotificationCenter.default.publisher(for: UIScene.didActivateNotification)) { notification in guard let windowScene, (notification.object as? UIScene) === windowScene else { return } Task { await model.start() } } +#endif .onReceive(NotificationCenter.default.publisher(for: UIScene.didEnterBackgroundNotification)) { notification in guard let windowScene, (notification.object as? UIScene) === windowScene else { return } model.cancelBiometrics()