From b3fd03c5c355bfb242cff6984c4bb7e10d36880a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 28 Oct 2026 17:35:41 +0100 Subject: [PATCH 01/42] refactor: migrate share extension to SwiftUI Signed-off-by: Marino Faggiana --- .github/workflows/xcode.yml | 2 +- AGENTS.md | 5 +- Brand/Share.plist | 4 +- Nextcloud.xcodeproj/project.pbxproj | 118 +++-- Share/Model/NCShareViewModel+Accounts.swift | 99 ++++ Share/Model/NCShareViewModel+Folders.swift | 123 +++++ Share/Model/NCShareViewModel+Upload.swift | 356 ++++++++++++++ Share/Model/NCShareViewModel.swift | 451 ++++++++++++++++++ Share/NCShareEntryController.swift | 28 ++ Share/NCShareLoaderItemProvider.swift | 359 ++++++++++++++ Share/NCShareSessionStore.swift | 35 ++ Share/{ => UIKit}/MainInterface.storyboard | 0 Share/{ => UIKit}/NCShareCell.swift | 0 .../NCShareExtension+DataSource.swift | 0 .../{ => UIKit}/NCShareExtension+Files.swift | 0 ...reExtension+NCAccountRequestDelegate.swift | 0 Share/{ => UIKit}/NCShareExtension.swift | 0 Share/{ => UIKit}/NCShareExtensionData.swift | 0 Share/View/NCShareAccountPickerView.swift | 60 +++ Share/View/NCShareUploadConflictView.swift | 82 ++++ Share/View/NCShareView.swift | 178 +++++++ .../NCShareLoaderItemProviderTests.swift | 199 ++++++++ .../NCShareUploadNamePlannerTests.swift | 69 +++ .../Networking/NCNetworking+Upload.swift | 6 +- .../en.lproj/Localizable.strings | 17 + 25 files changed, 2151 insertions(+), 40 deletions(-) create mode 100644 Share/Model/NCShareViewModel+Accounts.swift create mode 100644 Share/Model/NCShareViewModel+Folders.swift create mode 100644 Share/Model/NCShareViewModel+Upload.swift create mode 100644 Share/Model/NCShareViewModel.swift create mode 100644 Share/NCShareEntryController.swift create mode 100644 Share/NCShareLoaderItemProvider.swift create mode 100644 Share/NCShareSessionStore.swift rename Share/{ => UIKit}/MainInterface.storyboard (100%) rename Share/{ => UIKit}/NCShareCell.swift (100%) rename Share/{ => UIKit}/NCShareExtension+DataSource.swift (100%) rename Share/{ => UIKit}/NCShareExtension+Files.swift (100%) rename Share/{ => UIKit}/NCShareExtension+NCAccountRequestDelegate.swift (100%) rename Share/{ => UIKit}/NCShareExtension.swift (100%) rename Share/{ => UIKit}/NCShareExtensionData.swift (100%) create mode 100644 Share/View/NCShareAccountPickerView.swift create mode 100644 Share/View/NCShareUploadConflictView.swift create mode 100644 Share/View/NCShareView.swift create mode 100644 Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift create mode 100644 Tests/NextcloudUnitTests/NCShareUploadNamePlannerTests.swift diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 2765b5e1cf..8d84d53a84 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -100,7 +100,7 @@ jobs: iOSClient/Settings/NCPreferences.swift | \ iOSClient/SceneDelegate.swift | \ iOSClient/AppDelegate.swift | \ - Share/NCShareExtension.swift | \ + Share/* | \ Brand/* | \ Tests/NextcloudUITests/* | \ Tests/TestConstants.swift | \ diff --git a/AGENTS.md b/AGENTS.md index 18c62c6bca..86610a28e2 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 and session storage. | — | +| `Share/View` | Contains the SwiftUI Share extension interface. | — | +| `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/Brand/Share.plist b/Brand/Share.plist index ec2daea2b1..f5512c8a59 100755 --- a/Brand/Share.plist +++ b/Brand/Share.plist @@ -32,8 +32,8 @@ 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 diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 99c797f34f..c2e186913b 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -7,6 +7,20 @@ objects = { /* Begin PBXBuildFile section */ + AB1028260000000000060001 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.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 */; }; + AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; + AB1028260000000000040001 /* NCShareLoaderItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */; }; + AB1028260000000000040002 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB102826000000000003000A /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; + AB1028260000000000030009 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; + AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; + AB1028260000000000030006 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB1028260000000000030004 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; + AB1028260000000000030003 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; + AB1028260000000000030002 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; + AB1028260000000000030000 /* NCShareEntryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020000 /* NCShareEntryController.swift */; }; 22BA14432CF44BDBA2E6C291 /* NCDocumentEditorSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74B947E07444CC9CFE0A6C /* NCDocumentEditorSupportTests.swift */; }; 2C1D5D7623E2DE3300334ABB /* NCManageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BAADB51ED5A87C00B7EAD4 /* NCManageDatabase.swift */; }; 2C1D5D7923E2DE9100334ABB /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; @@ -80,13 +94,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 */; }; @@ -101,7 +108,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 */; }; @@ -280,8 +286,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 */; }; @@ -836,7 +840,6 @@ 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 */; }; @@ -854,7 +857,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 */; }; @@ -1078,8 +1080,6 @@ 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 */; }; @@ -1300,6 +1300,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AB1028260000000000060000 /* NCShareAccountPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountPickerView.swift; sourceTree = ""; }; + AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadNamePlannerTests.swift; sourceTree = ""; }; + AB1028260000000000050001 /* NCShareUploadConflictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadConflictView.swift; sourceTree = ""; }; + AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLoaderItemProviderTests.swift; sourceTree = ""; }; + AB102826000000000002000A /* NCShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel.swift"; sourceTree = ""; }; + AB1028260000000000020009 /* NCShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareView.swift"; sourceTree = ""; }; + AB1028260000000000020008 /* NCShareSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareSessionStore.swift"; sourceTree = ""; }; + AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareLoaderItemProvider.swift"; sourceTree = ""; }; + AB1028260000000000020004 /* NCShareViewModel+Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Upload.swift"; sourceTree = ""; }; + AB1028260000000000020003 /* NCShareViewModel+Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Folders.swift"; sourceTree = ""; }; + AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Accounts.swift"; sourceTree = ""; }; + AB1028260000000000020000 /* NCShareEntryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareEntryController.swift"; sourceTree = ""; }; 131F8E2F43AD414E9DB53019 /* NCPasscodeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCPasscodeModel.swift; sourceTree = ""; }; 2C33C47F23E2C475005F963B /* Notification Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Notification Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 2C33C48123E2C475005F963B /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -2272,6 +2284,41 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + AB1028260000000000070000 /* View */ = { + isa = PBXGroup; + children = ( + AB1028260000000000020009 /* NCShareView.swift */, + AB1028260000000000060000 /* NCShareAccountPickerView.swift */, + AB1028260000000000050001 /* NCShareUploadConflictView.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 = ""; + }; + AB102826000000000000FF00 /* UIKit */ = { + 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 */, + ); + path = UIKit; + sourceTree = ""; + }; 2C33C48023E2C475005F963B /* Notification Service Extension */ = { isa = PBXGroup; children = ( @@ -2336,6 +2383,8 @@ F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, 439616AAA50F4360A761E5EF /* NCAlbumsPersistenceTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, + AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */, + AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */, 4B74B947E07444CC9CFE0A6C /* NCDocumentEditorSupportTests.swift */, ); path = NextcloudUnitTests; @@ -3593,13 +3642,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 */, + AB1028260000000000020000 /* NCShareEntryController.swift */, + AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */, + AB1028260000000000020008 /* NCShareSessionStore.swift */, + AB1028260000000000070000 /* View */, + AB1028260000000000070001 /* Model */, + AB102826000000000000FF00 /* UIKit */, F7148046262EBE4B00693E51 /* Share-Bridging-Header.h */, ); path = Share; @@ -4456,20 +4504,16 @@ 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; @@ -4720,6 +4764,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 */, @@ -4805,7 +4853,6 @@ 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 */, @@ -4820,13 +4867,11 @@ 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 */, @@ -4858,9 +4903,7 @@ 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 */, @@ -4872,7 +4915,6 @@ 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 */, @@ -4882,7 +4924,6 @@ 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 */, @@ -4906,16 +4947,23 @@ 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 */, + AB1028260000000000060001 /* NCShareAccountPickerView.swift in Sources */, + AB102826000000000003000A /* NCShareViewModel.swift in Sources */, F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */, F76B3CCF1EAE01BD00921AC9 /* NCBrand.swift in Sources */, F72944F32A84246400246839 /* NCEndToEndMetadataV2.swift in Sources */, diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift new file mode 100644 index 0000000000..6fdc8ba85e --- /dev/null +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import NextcloudKit +import UIKit + +extension NCShareViewModel { + func showAccountPicker() { + guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, + !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isShowingInputError else { return } + isLoadingAccounts = true + accountPickerTask = Task { @MainActor in + defer { + isLoadingAccounts = false + showInputErrorIfNeeded() + } + 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_") { self.cancel(with: .noAccount) } + return + } + accounts = storedAccounts.map { account in + NCShareAccountItem( + id: account.account, + name: account.alias.isEmpty ? (account.displayName.isEmpty ? account.user : account.displayName) : account.alias, + user: account.user, + serverUrl: account.urlBase, + isSelected: account.account == selected) + }.sorted { $0.isSelected && !$1.isSelected } + if accounts.count > 1 { isShowingAccounts = true } + } + } + + func selectAccount(_ account: String) { + guard isAuthenticated, !isClosing, !isPreparingUpload, !isSwitchingAccount, + accounts.contains(where: { $0.id == account }) else { return } + pendingAccountSelection = account == sessionStore.getSession().account ? nil : account + isShowingAccounts = false + } + + func dismissAccountPicker() { + accountPickerTask?.cancel() + pendingAccountSelection = nil + isShowingAccounts = false + } + + func accountPickerDismissed() { + showInputErrorIfNeeded() + let selected = pendingAccountSelection + pendingAccountSelection = nil + guard let selected, isAuthenticated, !isClosing, !isPreparingUpload else { return } + changeAccount(selected) + } + + func changeAccount(_ account: String) { + guard !isClosing, !isPreparingUpload else { return } + accountChangeTask?.cancel() + let changeID = UUID() + accountChangeID = changeID + isSwitchingAccount = true + clearAccountView() + serverUrl = "" + accountChangeTask = Task { @MainActor 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.getTblAccoun(), tblAccount.account == account else { + showAlert(description: "_no_active_account_") { 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) + serverUrl = utilityFileSystem.getHomeServer(session: session) + setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) + showAccountView() + await reloadData() + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } + await loadFolder() + } + } +} diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift new file mode 100644 index 0000000000..c3d06f3e5b --- /dev/null +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +extension NCShareViewModel { + func reloadData() async { + let session = sessionStore.getSession() + let folderUrl = serverUrl + let layoutForView = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, serverUrl: folderUrl) + let showHiddenFiles = NCPreferences().getShowHiddenFiles(account: session.account) + let predicate = showHiddenFiles + ? NSPredicate( + format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true", + session.account, + folderUrl, + NextcloudKit.shared.nkCommonInstance.rootFileName + ) + : NSPredicate( + format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true AND NOT fileName BEGINSWITH[c] %@", + session.account, + folderUrl, + NextcloudKit.shared.nkCommonInstance.rootFileName, + "." + ) + + let metadatas = await NCManageDatabase.shared.getMetadatasAsync(predicate: predicate, + withLayout: layoutForView, + withAccount: session.account) + guard !Task.isCancelled, !isClosing, sessionStore.getSession().account == session.account, + serverUrl == folderUrl else { return } + folders = metadatas.map { metadata in + NCShareFolderItem( + id: metadata.ocId, + name: metadata.fileNameView, + image: metadata.e2eEncrypted + ? NCImageCache.shared.getFolderEncrypted(account: metadata.account) + : NCImageCache.shared.getFolder(account: metadata.account) + ) + } + } + + 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 = sessionStore.getSession() + let folderUrl = serverUrl + isLoading = true + defer { + if sessionStore.getSession().account == session.account, serverUrl == folderUrl { + isLoading = false + } + } + let result = await NCNetworking.shared.readFolderAsync(serverUrl: folderUrl, account: session.account) { [weak self] task in + Task { @MainActor [weak self] in + guard let self, !self.isClosing, self.sessionStore.getSession().account == session.account, + self.serverUrl == folderUrl else { + task.cancel() + return + } + self.dataSourceTask = task + } + } + guard !Task.isCancelled, !isClosing, sessionStore.getSession().account == session.account, + serverUrl == folderUrl else { return } + if result.error == .success { + metadataFolder = result.metadataFolder + await reloadData() + } else { + showAlert(description: result.error.errorDescription) + } + } +} + +extension NCShareViewModel { + func openFolder(_ folder: NCShareFolderItem) { + guard isAuthenticated, !isClosing, !isSwitchingAccount, !isLoading, !isPreparingUpload else { return } + let origin = serverUrl + Task { + guard let presenter else { return } + guard let tblAccount = sessionStore.getTblAccoun(), + let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(folder.id) else { + return self.showAlert(description: "_invalid_url_") + } + guard !Task.isCancelled, !isClosing, !isSwitchingAccount, + metadata.account == sessionStore.getSession().account, metadata.serverUrl == origin else { return } + 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) + guard !Task.isCancelled, !isClosing, !isSwitchingAccount, + sessionStore.getSession().account == tblAccount.account, self.serverUrl == origin else { return } + + 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: presenter) + return + } + + self.serverUrl = serverUrl + self.setNavigationBar(navigationTitle: metadata.fileNameView) + + await self.reloadData() + await self.loadFolder() + } + } +} diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift new file mode 100644 index 0000000000..449bb77793 --- /dev/null +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -0,0 +1,356 @@ +// 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 +import UIKit +import NextcloudKit +import LucidBanner + +// MARK: - Upload +extension NCShareViewModel { + func actionUpload() { + uploadTask = Task { @MainActor in + defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } + do { + let plan = try await prepareUploadPlan() + try Task.checkCancellation() + guard !isClosing, isAuthenticated else { return } + if plan.requiresConfirmation { + pendingUploadConfirmation = plan + uploadConfirmation = plan + } else { + try await stageAndUpload(plan: plan, replacing: false) + } + } catch is CancellationError { + // The extension was closed; do not present another screen. + } catch { + if !isClosing { showAlert(description: error.localizedDescription) } + } + } + } + + func resolveUploadConfirmation(_ choice: NCShareUploadChoice?) { + guard pendingUploadConfirmation != nil else { return } + uploadChoice = choice + uploadConfirmation = nil + } + + func uploadConfirmationDismissed() { + guard let plan = pendingUploadConfirmation else { return } + let choice = uploadChoice + pendingUploadConfirmation = nil + uploadChoice = nil + guard let choice, isAuthenticated, !isClosing else { + isPreparingUpload = false + return + } + uploadTask = Task { @MainActor in + defer { isPreparingUpload = false } + do { + try await stageAndUpload(plan: plan, replacing: choice == .replace) + } catch is CancellationError { + // No upload after cancellation or a destination change. + } catch { + if !isClosing { showAlert(description: error.localizedDescription) } + } + } + } + + private func prepareUploadPlan() async throws -> NCShareUploadConfirmation { + let session = sessionStore.getSession() + let destination = serverUrl + let sourceNames = filesName + guard !sourceNames.isEmpty, let capabilities = NCNetworking.shared.capabilities[session.account] else { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) + } + 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, 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: "")) + } + 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) + metadataFolder = listing.metadataFolder + 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) + } + + private func stageAndUpload(plan: NCShareUploadConfirmation, replacing: Bool) async throws { + try Task.checkCancellation() + let session = sessionStore.getSession() + guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl, + let capabilities = NCNetworking.shared.capabilities[session.account] else { throw CancellationError() } + uploadMetadata.removeAll() + replacementETags.removeAll() + counterUploaded = 0 + var stagedDirectories: [String] = [] + var staged = false + defer { + if !staged { + for directory in stagedDirectories { try? FileManager.default.removeItem(atPath: directory) } + uploadMetadata.removeAll() + replacementETags.removeAll() + } + } + 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: sceneIdentifier) + 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) + if replacing, let etag = plan.replacementETag { + replacementETags[ocId] = etag.hasPrefix("\"") || etag.hasPrefix("W/") ? etag : "\"" + etag + "\"" + } + } + try Task.checkCancellation() + guard !isClosing, isAuthenticated, sessionStore.getSession().account == plan.account, serverUrl == plan.serverUrl else { throw CancellationError() } + staged = true + 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? + guard let window = presenter?.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 { + guard !Task.isCancelled, !isClosing else { return } + // 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 { + break + } + } + + if error == .success { + banner?.update(payload: LucidBannerPayload.Update(stage: .success, horizontalLayout: .centered(width: 100)), for: self.token) + } else { + banner?.update(payload: LucidBannerPayload.Update(subtitle: error?.errorDescription, stage: .error), for: self.token) + } + + guard !Task.isCancelled, !isClosing else { return } + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + banner?.dismiss() + cancel() + } + } + + @MainActor + func upload(metadata: tableMetadata) async -> NKError? { + let session = sessionStore.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: presenter, + 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, + overwrite: self.replacementETags[metadata.ocId] != nil, + ifMatch: self.replacementETags[metadata.ocId]) { 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, + customHeaders: replacementETags[metadata.ocId].map { ["If-Match": $0] } ?? ["If-None-Match": "*"]) { _ in + } progressHandler: { _, _, fractionCompleted in + Task {@MainActor in + self.banner?.update(payload: LucidBannerPayload.Update(progress: fractionCompleted), + for: self.token) + } + } + error = results.error + } + + return 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 + var reserved = Set(names) + var assigned: Set = [] + return names.map { name in + if !occupied.contains(name), !assigned.contains(name) { + assigned.insert(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[..? + var replacementETags: [String: String] = [:] + var isClosing = false + @Published var isPreparingInput = false + @Published var isShowingInputError = false + @Published var inputErrorMessage = "" + @Published var navigationTitle = NCBrandOptions.shared.brand + @Published var accountName = "" + @Published var accounts: [NCShareAccountItem] = [] + @Published var isShowingAccounts = false + @Published var isLoadingAccounts = false + @Published var isSwitchingAccount = false + var pendingAccountSelection: String? + var accountPickerTask: Task? + var accountChangeTask: Task? + var accountChangeID = UUID() + @Published var canGoBack = false + @Published private(set) var isAuthenticated = false + + weak var presenter: UIViewController? + var requestContext: NSExtensionContext? + lazy var sessionStore = NCShareSessionStore() + var serverUrl = "" + var filesName: [String] { + get { fileNames } + set { fileNames = newValue } + } + let inputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("NextcloudShare-" + UUID().uuidString, isDirectory: true) + private var inputTask: Task? + private var hasStarted = false + private var foregroundObserver: NSObjectProtocol? + private var backgroundObserver: NSObjectProtocol? + + let keyLayout = NCGlobal.shared.layoutViewShareExtension + var metadataFolder: tableMetadata? + var dataSourceTask: URLSessionTask? + 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 + + func start(context: NSExtensionContext?, presenter: UIViewController) { + guard !hasStarted else { return } + hasStarted = true + 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?.dismissAccountPicker() } + } + 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.dismissAccountPicker() + guard !self.maintenanceMode, self.validateAccount() else { return } + self.resolveUploadConfirmation(nil) + 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 + appear() + return + } + } + + if let account = sessionStore.getTblAccoun()?.account { + changeAccount(account) + } + + NCNetworking.shared.setupScene(sceneIdentifier: sceneIdentifier, controller: presenter) + + if let windowScene = presenter.view.window?.windowScene { + banner = LucidBannerRegistry.shared.banner(for: windowScene) + } + appear() + } + + private func appear() { + guard !maintenanceMode else { + return showAlert(description: "_version_mismatch_error_") { + self.cancel(with: .versionMismatch) + } + } + guard validateAccount() else { return } + requestPasscodeIfNeeded() + 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.filesName = 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") + self.showInputErrorIfNeeded() + } + } 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 + self.showInputErrorIfNeeded() + } + } + } + + func inputFileURL(named fileName: String) -> URL { + inputDirectory.appendingPathComponent(fileName) + } + + func showInputErrorIfNeeded() { + // Do not present import errors over the passcode screen. + if isAuthenticated && !isShowingAccounts && !isLoadingAccounts && !inputErrorMessage.isEmpty { + isShowingInputError = true + } + } + + func acknowledgeInputError() { + inputErrorMessage = "" + if filesName.isEmpty { cancel(with: .noFiles) } + } + + isolated deinit { + inputTask?.cancel() + uploadTask?.cancel() + accountPickerTask?.cancel() + accountChangeTask?.cancel() + dataSourceTask?.cancel() + if !isPreparingInput { try? FileManager.default.removeItem(at: inputDirectory) } + if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } + if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } + } + + private func validateAccount() -> Bool { + guard !NCPasscodeModel.isPasscodeReset else { + return showNoAccountAlert() + } + + if let account = sessionStore.getTblAccoun()?.account, + NCManageDatabase.shared.getTableAccount(account: account) != nil { + return true + } + + guard let activeAccount = NCManageDatabase.shared.getActiveTableAccount() else { + return showNoAccountAlert() + } + + clearAccountView() + changeAccount(activeAccount.account) + return true + } + + private func showNoAccountAlert() -> Bool { + clearAccountView() + if !isPresentingNoAccountAlert { + isPresentingNoAccountAlert = true + showAlert(description: "_no_active_account_") { + self.cancel(with: .noAccount) + } + } + return false + } + + func clearAccountView() { + dataSourceTask?.cancel() + dataSourceTask = nil + metadataFolder = nil + isAvailable = false + folders = [] + accountName = "" + canGoBack = false + navigationTitle = NCBrandOptions.shared.brand + } + + func showAccountView() { + isAvailable = true + } + + func cancel(with error: NCShareRequestError? = nil) { + isClosing = true + dismissAccountPicker() + uploadConfirmation = nil + pendingUploadConfirmation = nil + uploadChoice = nil + inputTask?.cancel() + uploadTask?.cancel() + accountPickerTask?.cancel() + accountChangeTask?.cancel() + dataSourceTask?.cancel() + if !isPreparingInput { try? FileManager.default.removeItem(at: inputDirectory) } + if let error { + requestContext?.cancelRequest(withError: error) + } else { + requestContext?.completeRequest(returningItems: requestContext?.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?() + })) + presenter?.present(alertController, animated: true) + } + + func setNavigationBar(navigationTitle: String) { + self.navigationTitle = navigationTitle + guard let account = sessionStore.getTblAccoun() else { return } + accountName = account.alias.isEmpty ? account.displayName : account.alias + canGoBack = serverUrl != utilityFileSystem.getHomeServer(session: sessionStore.getSession()) + } + + func goBack() { + guard canGoBack, !isLoading, !isPreparingUpload, !isSwitchingAccount else { return } + serverUrl = (serverUrl as NSString).deletingLastPathComponent + let home = utilityFileSystem.getHomeServer(session: sessionStore.getSession()) + setNavigationBar(navigationTitle: serverUrl == home ? NCBrandOptions.shared.brand : (serverUrl as NSString).lastPathComponent) + Task { + await reloadData() + await loadFolder() + } + } + + func setCommandView() { + if filesName.isEmpty { cancel(with: .noFiles) } + } + + func createFolder() { + let session = sessionStore.getSession() + guard let capabilities = NCNetworking.shared.capabilities[session.account] else { return } + let alert = UIAlertController.createFolderWith(serverUrl: serverUrl, session: session, capabilities: capabilities) { [weak self] error in + guard let self else { return } + if error == .success { + Task { await self.loadFolder() } + } else { + self.showAlert(title: "_error_createsubfolders_upload_", description: error.errorDescription) + } + } + presenter?.present(alert, animated: true) + } + + 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) + } + + presenter?.present(alert, animated: true) + } + + func renameFile(oldName: String, newName: String, account: String) { + guard let fileIx = self.filesName.firstIndex(of: oldName), + !self.filesName.contains(newName), + newName == (newName as NSString).lastPathComponent, + newName != ".", newName != "..", + utilityFileSystem.moveFile(atPath: inputFileURL(named: oldName).path, toPath: inputFileURL(named: newName).path) else { + return showAlert(title: "_single_file_conflict_title_", description: "'\(oldName)' -> '\(newName)'") + } + + filesName[fileIx] = newName + + } + + func removeFile(named fileName: String) { + guard let index = self.filesName.firstIndex(of: fileName) else { + return showAlert(title: "_file_not_found_", description: fileName) + } + try? FileManager.default.removeItem(at: inputFileURL(named: fileName)) + self.filesName.remove(at: index) + if self.filesName.isEmpty { + cancel(with: NCShareRequestError.noFiles) + } else { + self.setCommandView() + } + } + + func rename(_ fileName: String) { + showRenameFileDialog(named: fileName, account: sessionStore.getSession().account) + } + + func remove(_ fileName: String) { + removeFile(named: fileName) + } + + func upload() { + guard isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, + !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isLoading, !fileNames.isEmpty else { return } + isPreparingUpload = true + actionUpload() + } +} + +extension NCShareViewModel { + /// Require authentication once per foreground session without reopening after dismissal. + private func requestPasscodeIfNeeded() { + if !NCPreferences().presentPasscode { + isAuthenticated = true + showInputErrorIfNeeded() + return + } + isAuthenticated = false + isShowingInputError = false + guard NCPreferences().presentPasscode, + !isPasscodeAuthenticated, + !isPresentingPasscode, + let presenter else { return } + isPresentingPasscode = true + // Embed in the content controller; the helper hides the outer navigation bar. + NCPasscodeView.embed(in: presenter) { [weak self] result in + guard let self else { return } + self.isPresentingPasscode = false + switch result { + case .authenticated: + self.isPasscodeAuthenticated = true + if self.validateAccount() { + self.isAuthenticated = true + self.showInputErrorIfNeeded() + } + case .cancelled, .reset, .configured, .disabled: + self.cancel(with: .noAccount) + } + } + } +} + +/// A UI snapshot, so SwiftUI does not retain live Realm objects. +struct NCShareFolderItem: Identifiable { + let id: String + let name: String + let image: UIImage? +} + +enum NCShareRequestError: Error { + case cancel, fileUpload, noAccount, noFiles, versionMismatch +} + +struct NCShareUploadItem: Sendable { + let sourceName: String + let fileName: String +} + +struct NCShareUploadConfirmation: Identifiable, Sendable { + let id = UUID() + 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, Sendable { + let id: String + let name: String + let user: String + let serverUrl: String + let isSelected: Bool +} + +#if DEBUG +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.isAvailable = true + model.isAuthenticated = true + model.accountName = "Personal" + model.folders = [ + NCShareFolderItem(id: "documents", name: "Documents", image: UIImage(systemName: "folder.fill")), + NCShareFolderItem(id: "photos", name: "Photos", image: UIImage(systemName: "folder.fill")), + NCShareFolderItem(id: "shared", name: "Shared with the team", image: UIImage(systemName: "person.2.fill")) + ] + 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/NCShareEntryController.swift b/Share/NCShareEntryController.swift new file mode 100644 index 0000000000..04e0e9105a --- /dev/null +++ b/Share/NCShareEntryController.swift @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +/// Loads the SwiftUI interface directly as the extension's principal controller. +@MainActor +final class NCShareEntryController: UIHostingController { + private let model: NCShareViewModel + + // 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 viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + model.start(context: extensionContext, presenter: self) + } +} diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift new file mode 100644 index 0000000000..f1a5275655 --- /dev/null +++ b/Share/NCShareLoaderItemProvider.swift @@ -0,0 +1,359 @@ +// 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 + + 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(_ result: Result) { + 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() + } + + 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 + } + } + + if provider.canLoadObject(ofClass: UIImage.self) { + return try await loadImage(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier), + provider.canLoadObject(ofClass: NSURL.self) { + return try await loadLink(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } + if provider.canLoadObject(ofClass: NSString.self) { + return try await loadText(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + } + 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 + guard !pending.isCancelled else { return } + do { + if let error { throw error } + guard let url else { throw CocoaError(.fileReadUnknown) } + let result = try copy(source: url, name: name, fallbackName: fallbackName, type: type, directory: directory) + pending.finish(.success(result)) + } catch { + pending.finish(.failure(error)) + } + } + 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 + guard !pending.isCancelled else { return } + do { + if let error { throw error } + guard let data else { throw CocoaError(.fileReadUnknown) } + let destination = destination(name: name ?? fallbackName, type: type, directory: directory) + try data.write(to: destination, options: .atomic) + pending.finish(.success(destination.lastPathComponent)) + } catch { + pending.finish(.failure(error)) + } + } + 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 + guard !pending.isCancelled else { return } + do { + if let error { throw error } + guard let image = object as? UIImage, + let data = autoreleasepool(invoking: { image.pngData() }) else { throw CocoaError(.fileReadCorruptFile) } + let destination = destination(name: name ?? fallbackName, type: .png, directory: directory) + try data.write(to: destination, options: .atomic) + pending.finish(.success(destination.lastPathComponent)) + } catch { + pending.finish(.failure(error)) + } + } + 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 + guard !pending.isCancelled else { return } + do { + if let error { throw error } + guard let text = object as? String else { throw CocoaError(.fileReadCorruptFile) } + let destination = destination(name: name ?? fallbackName, type: .utf8PlainText, directory: directory) + try Data(text.utf8).write(to: destination, options: .atomic) + pending.finish(.success(destination.lastPathComponent)) + } catch { + pending.finish(.failure(error)) + } + } + 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 + guard !pending.isCancelled else { return } + do { + 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) + pending.finish(.success(.file(name))) + } else { + pending.finish(.success(.remote(url))) + } + } catch { + pending.finish(.failure(error)) + } + } + 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 + guard !pending.isCancelled else { return } + do { + 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) + pending.finish(.success(destination.lastPathComponent)) + } catch { + pending.finish(.failure(error)) + } + } + task.resume() + return { task.cancel() } + } + } + + 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" } + if let type, let preferredExtension = 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/NCShareSessionStore.swift b/Share/NCShareSessionStore.swift new file mode 100644 index 0000000000..7807f4be00 --- /dev/null +++ b/Share/NCShareSessionStore.swift @@ -0,0 +1,35 @@ +// 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 var tblAccount: 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 } + let capabilities = await NCManageDatabase.shared.getCapabilities(account: account) + guard !Task.isCancelled else { return nil } + tblAccount = selected + if let capabilities { + 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) + } + return NCSession.Session(account: "", urlBase: "", user: "", userId: "") + } + + func getTblAccoun() -> tableAccount? { + tblAccount + } +} diff --git a/Share/MainInterface.storyboard b/Share/UIKit/MainInterface.storyboard similarity index 100% rename from Share/MainInterface.storyboard rename to Share/UIKit/MainInterface.storyboard diff --git a/Share/NCShareCell.swift b/Share/UIKit/NCShareCell.swift similarity index 100% rename from Share/NCShareCell.swift rename to Share/UIKit/NCShareCell.swift diff --git a/Share/NCShareExtension+DataSource.swift b/Share/UIKit/NCShareExtension+DataSource.swift similarity index 100% rename from Share/NCShareExtension+DataSource.swift rename to Share/UIKit/NCShareExtension+DataSource.swift diff --git a/Share/NCShareExtension+Files.swift b/Share/UIKit/NCShareExtension+Files.swift similarity index 100% rename from Share/NCShareExtension+Files.swift rename to Share/UIKit/NCShareExtension+Files.swift diff --git a/Share/NCShareExtension+NCAccountRequestDelegate.swift b/Share/UIKit/NCShareExtension+NCAccountRequestDelegate.swift similarity index 100% rename from Share/NCShareExtension+NCAccountRequestDelegate.swift rename to Share/UIKit/NCShareExtension+NCAccountRequestDelegate.swift diff --git a/Share/NCShareExtension.swift b/Share/UIKit/NCShareExtension.swift similarity index 100% rename from Share/NCShareExtension.swift rename to Share/UIKit/NCShareExtension.swift diff --git a/Share/NCShareExtensionData.swift b/Share/UIKit/NCShareExtensionData.swift similarity index 100% rename from Share/NCShareExtensionData.swift rename to Share/UIKit/NCShareExtensionData.swift diff --git a/Share/View/NCShareAccountPickerView.swift b/Share/View/NCShareAccountPickerView.swift new file mode 100644 index 0000000000..6480115ee0 --- /dev/null +++ b/Share/View/NCShareAccountPickerView.swift @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +@MainActor +struct NCShareAccountPickerView: View { + @ObservedObject var model: NCShareViewModel + + var body: some View { + NavigationStack { + List(model.accounts) { account in + Button { + model.selectAccount(account.id) + } label: { + HStack(spacing: 12) { + Image(systemName: "person.crop.circle.fill") + .font(.title) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: account.name) + .foregroundStyle(.primary) + Text(verbatim: account.user) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(verbatim: account.serverUrl) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if account.isSelected { + Image(systemName: "checkmark") + .accessibilityHidden(true) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(account.isSelected ? .isSelected : []) + } + .navigationTitle(NSLocalizedString("_account_select_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("_cancel_", comment: "")) { model.dismissAccountPicker() } + } + } + } + } +} + +#if DEBUG +#Preview("Accounts") { + NCShareAccountPickerView(model: .preview()) + .allowsHitTesting(false) +} +#endif diff --git a/Share/View/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift new file mode 100644 index 0000000000..203504b0bd --- /dev/null +++ b/Share/View/NCShareUploadConflictView.swift @@ -0,0 +1,82 @@ +// 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 { + NavigationStack { + List { + Section { + Text(confirmation.isBatch + ? NSLocalizedString("_share_conflict_batch_message_", comment: "") + : String(format: NSLocalizedString("_share_conflict_single_message_", comment: ""), confirmation.items[0].sourceName)) + } + Section(NSLocalizedString("_share_conflict_new_names_", comment: "")) { + ForEach(confirmation.renamedItems, id: \.sourceName) { item in + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: item.sourceName) + .font(.caption) + .foregroundStyle(.secondary) + Text(verbatim: item.fileName) + } + } + } + Section { + if !confirmation.isBatch, confirmation.replacementFileName != nil { + Button(NSLocalizedString("_share_conflict_replace_", comment: ""), role: .destructive) { + resolve(.replace) + } + } + Button(confirmation.isBatch + ? NSLocalizedString("_upload_", comment: "") + : NSLocalizedString("_share_conflict_keep_both_", comment: "")) { + resolve(.keepBoth) + } + } + } + .navigationTitle(NSLocalizedString("_share_conflict_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("_cancel_", comment: "")) { resolve(nil) } + } + } + } + } +} + +#if DEBUG +#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..d72499c718 --- /dev/null +++ b/Share/View/NCShareView.swift @@ -0,0 +1,178 @@ +// 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 + + var body: some View { + NavigationStack { + Group { + if model.isAvailable && model.isAuthenticated { + VStack(spacing: 0) { + folderList + if model.isPreparingInput { + ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) + .padding() + } + if model.isPreparingUpload { + ProgressView(NSLocalizedString("_share_conflict_checking_", comment: "")) + .padding() + } + commands + } + } else if model.isSwitchingAccount && model.isAuthenticated { + ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + } else { + Color(uiColor: .systemBackground) + } + } + .background(Color(uiColor: .systemBackground)) + .alert(NSLocalizedString("_error_", comment: ""), isPresented: $model.isShowingInputError) { + Button(NSLocalizedString("_ok_", comment: "")) { model.acknowledgeInputError() } + } message: { + Text(verbatim: model.inputErrorMessage) + } + .sheet(item: $model.uploadConfirmation, onDismiss: { model.uploadConfirmationDismissed() }) { confirmation in + NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) + } + .sheet(isPresented: $model.isShowingAccounts, onDismiss: { model.accountPickerDismissed() }) { + NCShareAccountPickerView(model: model) + } + .navigationTitle(model.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + if model.isAvailable && model.isAuthenticated { + if model.canGoBack { + Button(action: model.goBack) { + Label(NSLocalizedString("_back_", comment: ""), systemImage: "chevron.left") + } + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + } + Button(action: model.showAccountPicker) { + Label(model.accountName, systemImage: "person.crop.circle") + .lineLimit(1) + } + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + } + } + ToolbarItem(placement: .topBarTrailing) { + Button(NSLocalizedString("_cancel_", comment: "")) { model.cancel() } + } + } + } + } + + private var folderList: some View { + List(model.folders) { folder in + Button { + model.openFolder(folder) + } label: { + HStack(spacing: 12) { + if let image = folder.image { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(width: 36, height: 36) + } + Text(verbatim: folder.name) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .listStyle(.plain) + .overlay { + if model.folders.isEmpty { + if model.isLoading { + ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + } else { + ContentUnavailableView(NSLocalizedString("_files_no_folders_", comment: ""), systemImage: "folder") + } + } + } + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + } + + private var commands: some View { + VStack(spacing: 12) { + Button(action: model.createFolder) { + Label(NSLocalizedString("_create_folder_", comment: ""), systemImage: "folder.badge.plus") + .frame(maxWidth: .infinity, alignment: .leading) + } + List(model.fileNames, id: \.self) { fileName in + HStack { + Button { + model.rename(fileName) + } label: { + Label { + Text(verbatim: fileName).lineLimit(2) + } icon: { + Image(systemName: "doc") + } + .foregroundStyle(.primary) + } + .buttonStyle(.plain) + Spacer() + Menu { + Button(NSLocalizedString("_rename_file_", comment: "")) { + model.rename(fileName) + } + Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { + model.remove(fileName) + } + } label: { + Image(systemName: "ellipsis.circle") + .frame(minWidth: 44, minHeight: 44) + } + .accessibilityLabel(Text(verbatim: fileName)) + } + } + .listStyle(.plain) + .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 64) + + Button(action: model.upload) { + Text(NSLocalizedString("_upload_", comment: "") + " \(model.fileNames.count) " + NSLocalizedString("_files_", comment: "")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(model.fileNames.isEmpty || model.isLoading || model.isShowingAccounts) + } + .padding() + .background(Color(uiColor: .secondarySystemBackground)) + .disabled(model.isPreparingUpload || model.isPreparingInput || model.isSwitchingAccount || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + } +} + +#if DEBUG +#Preview("Share") { + NCShareView(model: .preview()) + .allowsHitTesting(false) +} + +#Preview("No folders") { + let model = NCShareViewModel.preview() + model.folders = [] + return NCShareView(model: model) + .allowsHitTesting(false) +} + +#Preview("Preparing files") { + let model = NCShareViewModel.preview() + model.fileNames = [] + model.isPreparingInput = true + return NCShareView(model: model) + .allowsHitTesting(false) +} +#endif diff --git a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift new file mode 100644 index 0000000000..285ccc3e9c --- /dev/null +++ b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +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("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/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/Networking/NCNetworking+Upload.swift b/iOSClient/Networking/NCNetworking+Upload.swift index 97433aee68..2ae1b572c6 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 8f9bfda39a..28bca846df 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -884,3 +884,20 @@ "_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. You can keep both files, or replace the existing file when available."; +"_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"; +"_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."; From a5cc90213b4fa6bbc5509dff7461ab39553ff1f0 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 28 Oct 2026 17:50:01 +0100 Subject: [PATCH 02/42] fix: enable Share extension previews in the Nextcloud scheme Host Share sources in app Debug builds with SHARE_PREVIEWS and exclude them from Release builds. Gate preview code and document the required scheme. Signed-off-by: Marino Faggiana --- AGENTS.md | 2 +- Nextcloud.xcodeproj/project.pbxproj | 31 ++++++++++++++++++++++ Share/Model/NCShareViewModel+Upload.swift | 2 +- Share/Model/NCShareViewModel.swift | 2 +- Share/View/NCShareAccountPickerView.swift | 2 +- Share/View/NCShareUploadConflictView.swift | 2 +- Share/View/NCShareView.swift | 2 +- 7 files changed, 37 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 86610a28e2..cefccf4998 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Other platforms like Android are irrelevant for this project. | `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 the active Share extension entry point, launched through `NSExtensionPrincipalClass`, plus input preparation and session storage. | — | -| `Share/View` | Contains the SwiftUI Share extension interface. | — | +| `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. | — | diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index c2e186913b..ab5952b56b 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -7,6 +7,15 @@ objects = { /* Begin PBXBuildFile section */ + AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; + AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; + AB1028260000000000080006 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.swift */; }; + AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; + AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; + AB1028260000000000080003 /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; + AB1028260000000000080002 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; + AB1028260000000000080001 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; + AB1028260000000000080000 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; AB1028260000000000060001 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.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 */; }; @@ -5094,6 +5103,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 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 /* NCShareAccountPickerView.swift in Sources */, + AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */, + AB1028260000000000080008 /* NCShareView.swift in Sources */, F76BED77306D0000002E1442 /* NCLocalDatabase.swift in Sources */, F76BED78306D0000002E1442 /* NCLocalDatabaseMigrator.swift in Sources */, F76BED7C306D0000002E1442 /* NCLocalDatabase+PhotoLibraryAsset.swift in Sources */, @@ -6457,6 +6475,7 @@ F77B0F9B1D118A16002130FE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SHARE_PREVIEWS"; ALWAYS_SEARCH_USER_PATHS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; @@ -6479,6 +6498,18 @@ F77B0F9C1D118A16002130FE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + EXCLUDED_SOURCE_FILE_NAMES = ( + "$(inherited)", + "NCShareViewModel+Accounts.swift", + "NCShareViewModel+Folders.swift", + "NCShareViewModel+Upload.swift", + "NCShareViewModel.swift", + "NCShareLoaderItemProvider.swift", + "NCShareSessionStore.swift", + "NCShareAccountPickerView.swift", + "NCShareUploadConflictView.swift", + "NCShareView.swift", + ); ALWAYS_SEARCH_USER_PATHS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 449bb77793..cf7314fc25 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -5,7 +5,7 @@ import Foundation // The unit-test target compiles only the pure name planner below. -#if EXTENSION_SHARE +#if EXTENSION_SHARE || SHARE_PREVIEWS import UIKit import NextcloudKit import LucidBanner diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index a3d1a70fb1..76beccae0c 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -426,7 +426,7 @@ struct NCShareAccountItem: Identifiable, Sendable { let isSelected: Bool } -#if DEBUG +#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. diff --git a/Share/View/NCShareAccountPickerView.swift b/Share/View/NCShareAccountPickerView.swift index 6480115ee0..84e179dab9 100644 --- a/Share/View/NCShareAccountPickerView.swift +++ b/Share/View/NCShareAccountPickerView.swift @@ -52,7 +52,7 @@ struct NCShareAccountPickerView: View { } } -#if DEBUG +#if DEBUG && SHARE_PREVIEWS #Preview("Accounts") { NCShareAccountPickerView(model: .preview()) .allowsHitTesting(false) diff --git a/Share/View/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift index 203504b0bd..b1bcd063a1 100644 --- a/Share/View/NCShareUploadConflictView.swift +++ b/Share/View/NCShareUploadConflictView.swift @@ -51,7 +51,7 @@ struct NCShareUploadConflictView: View { } } -#if DEBUG +#if DEBUG && SHARE_PREVIEWS #Preview("Single file conflict") { NCShareUploadConflictView( confirmation: NCShareUploadConfirmation( diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index d72499c718..d2dc7f3cc6 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -155,7 +155,7 @@ struct NCShareView: View { } } -#if DEBUG +#if DEBUG && SHARE_PREVIEWS #Preview("Share") { NCShareView(model: .preview()) .allowsHitTesting(false) From ebab7cd7e5ef924274d6d31e086aa8ded4477c34 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 28 Oct 2026 18:10:29 +0100 Subject: [PATCH 03/42] feat: add folder tree and recent destinations to Share extension Persist the last three successful upload destinations per account and validate folders before selection. Add unit tests for recent destination storage. Signed-off-by: Marino Faggiana --- AGENTS.md | 2 +- Nextcloud.xcodeproj/project.pbxproj | 13 + Share/Model/NCShareViewModel+Accounts.swift | 4 +- Share/Model/NCShareViewModel+Folders.swift | 292 ++++++++++++------ Share/Model/NCShareViewModel+Upload.swift | 1 + Share/Model/NCShareViewModel.swift | 72 +++-- Share/NCShareRecentFoldersStore.swift | 46 +++ Share/View/NCShareView.swift | 158 +++++++--- .../NCShareRecentFoldersStoreTests.swift | 90 ++++++ .../en.lproj/Localizable.strings | 11 + 10 files changed, 538 insertions(+), 151 deletions(-) create mode 100644 Share/NCShareRecentFoldersStore.swift create mode 100644 Tests/NextcloudUnitTests/NCShareRecentFoldersStoreTests.swift diff --git a/AGENTS.md b/AGENTS.md index cefccf4998..7fe8ec2350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ 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 the active Share extension entry point, launched through `NSExtensionPrincipalClass`, plus input preparation and session storage. | — | +| `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. | diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index ab5952b56b..b9365fafc8 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + AB1028260000000000090121 /* NCShareRecentFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */; }; + AB1028260000000000090120 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; + AB1028260000000000090110 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; + AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; AB1028260000000000080006 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.swift */; }; @@ -1309,6 +1313,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStoreTests.swift; sourceTree = ""; }; + AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStore.swift; sourceTree = ""; }; AB1028260000000000060000 /* NCShareAccountPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountPickerView.swift; sourceTree = ""; }; AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadNamePlannerTests.swift; sourceTree = ""; }; AB1028260000000000050001 /* NCShareUploadConflictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadConflictView.swift; sourceTree = ""; }; @@ -2392,6 +2398,7 @@ F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, 439616AAA50F4360A761E5EF /* NCAlbumsPersistenceTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, + AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */, AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */, AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */, 4B74B947E07444CC9CFE0A6C /* NCDocumentEditorSupportTests.swift */, @@ -3651,6 +3658,7 @@ F7C0F46D1C8880540059EC54 /* Share */ = { isa = PBXGroup; children = ( + AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */, AB1028260000000000020000 /* NCShareEntryController.swift */, AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */, AB1028260000000000020008 /* NCShareSessionStore.swift */, @@ -4759,6 +4767,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1028260000000000090120 /* NCShareRecentFoldersStore.swift in Sources */, + AB1028260000000000090121 /* NCShareRecentFoldersStoreTests.swift in Sources */, AC13ADAF2026101300000002 /* NCMediaAdaptiveLayoutViewTests.swift in Sources */, C0DEDA7E30C1000100A00002 /* NCAutoUploadDateTests.swift in Sources */, C0DEA070306D000100000008 /* NCAutoUploadAssetTests.swift in Sources */, @@ -4857,6 +4867,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */, F7864ACF2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift in Sources */, F71F6D0A2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */, F7245925289BB59100474787 /* ThreadSafeDictionary.swift in Sources */, @@ -5103,6 +5114,7 @@ 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 */, @@ -6499,6 +6511,7 @@ isa = XCBuildConfiguration; buildSettings = { EXCLUDED_SOURCE_FILE_NAMES = ( + NCShareRecentFoldersStore.swift, "$(inherited)", "NCShareViewModel+Accounts.swift", "NCShareViewModel+Folders.swift", diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 6fdc8ba85e..0684aaa4c9 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -91,9 +91,7 @@ extension NCShareViewModel { serverUrl = utilityFileSystem.getHomeServer(session: session) setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) showAccountView() - await reloadData() - guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } - await loadFolder() + await initializeFolderBrowser() } } } diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index c3d06f3e5b..4f7774eecb 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -6,118 +6,234 @@ import UIKit import NextcloudKit extension NCShareViewModel { - func reloadData() async { + 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) } + } + } + for folder in folders { append(folder, depth: 0) } + return rows + } + + func resetFolderBrowser() { + folderBrowserID = UUID() + folderSelectionTask?.cancel() + for task in folderTasks.values { task.cancel() } + for request in folderRequests.values { request.cancel() } + folderTasks.removeAll() + folderRequests.removeAll() + folders = [] + recentFolders = [] + selectedFolder = nil + folderChildren = [:] + expandedFolderURLs = [] + loadingFolderURLs = [] + loadedFolderURLs = [] + folderLoadErrors = [:] + isLoading = false + } + + func initializeFolderBrowser() async { let session = sessionStore.getSession() - let folderUrl = serverUrl - let layoutForView = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, serverUrl: folderUrl) - let showHiddenFiles = NCPreferences().getShowHiddenFiles(account: session.account) - let predicate = showHiddenFiles - ? NSPredicate( - format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true", - session.account, - folderUrl, - NextcloudKit.shared.nkCommonInstance.rootFileName - ) - : NSPredicate( - format: "account == %@ AND serverUrl == %@ AND fileName != %@ AND directory == true AND NOT fileName BEGINSWITH[c] %@", - session.account, - folderUrl, - NextcloudKit.shared.nkCommonInstance.rootFileName, - "." - ) - - let metadatas = await NCManageDatabase.shared.getMetadatasAsync(predicate: predicate, - withLayout: layoutForView, - withAccount: session.account) - guard !Task.isCancelled, !isClosing, sessionStore.getSession().account == session.account, - serverUrl == folderUrl else { return } - folders = metadatas.map { metadata in + 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) + folders = [root] + selectedFolder = root + expandedFolderURLs = [home] + recentFolders = recentFoldersStore.folders(account: session.account).filter { + $0.serverUrl == home || $0.serverUrl.hasPrefix(home + "/") + }.map { item in NCShareFolderItem( - id: metadata.ocId, - name: metadata.fileNameView, - image: metadata.e2eEncrypted - ? NCImageCache.shared.getFolderEncrypted(account: metadata.account) - : NCImageCache.shared.getFolder(account: metadata.account) - ) + 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) } + isLoading = true + defer { if folderBrowserID == browserID { isLoading = false } } + let cached = await cachedFolderChildren(at: home, parent: nil, session: session) + guard isCurrentBrowser(browserID, account: session.account) else { return } + folderChildren[home] = cached + await loadFolderChildren(root, browserID: browserID) } - 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 } + 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) } + } + } - self.serverUrl += "/" + metadata.fileName - await self.loadFolder() - self.setNavigationBar(navigationTitle: metadata.fileNameView) + func retryFolder(_ folder: NCShareFolderItem) { + guard canUseFolderBrowser, canAccessFolder(folder) else { return } + expandedFolderURLs.insert(folder.serverUrl) + requestFolderChildren(folder, refresh: true) + } + + func requestFolderChildren(_ folder: NCShareFolderItem, refresh: Bool = false) { + guard !isClosing, !loadingFolderURLs.contains(folder.serverUrl), + refresh || !loadedFolderURLs.contains(folder.serverUrl) else { return } + let browserID = folderBrowserID + // Mark synchronously, before scheduling, to prevent duplicate requests. + loadingFolderURLs.insert(folder.serverUrl) + folderTasks[folder.serverUrl] = Task { @MainActor in + await loadFolderChildren(folder, browserID: browserID) } } - func loadFolder() async { + func selectFolder(_ folder: NCShareFolderItem) { + guard canUseFolderBrowser, canAccessFolder(folder) else { return } let session = sessionStore.getSession() - let folderUrl = serverUrl + 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 } + } + 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 } + let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) + 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 + } + serverUrl = folder.serverUrl + metadataFolder = metadata + selectedFolder = selected + setNavigationBar(navigationTitle: selected.name) + } + } + + func recordRecentDestination() { + guard let uploaded = uploadMetadata.first, let folder = selectedFolder, + uploaded.serverUrl == folder.serverUrl else { return } + recentFoldersStore.record(.init( + serverUrl: folder.serverUrl, name: folder.name, ocId: folder.ocId, isEncrypted: folder.isEncrypted), + account: uploaded.account) + } + + private var canUseFolderBrowser: Bool { + isAuthenticated && !isClosing && !isSwitchingAccount && !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(title: "_info_", description: "_e2e_goto_settings_for_enable_") + return false + } + return true + } + + private 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 } + loadingFolderURLs.insert(folder.serverUrl) + folderLoadErrors[folder.serverUrl] = nil defer { - if sessionStore.getSession().account == session.account, serverUrl == folderUrl { - isLoading = false + if folderBrowserID == browserID { + loadingFolderURLs.remove(folder.serverUrl) + folderRequests[folder.serverUrl] = nil + folderTasks[folder.serverUrl] = nil } } - let result = await NCNetworking.shared.readFolderAsync(serverUrl: folderUrl, account: session.account) { [weak self] task in + let result = await NCNetworking.shared.readFolderAsync(serverUrl: folder.serverUrl, account: session.account) { [weak self] task in Task { @MainActor [weak self] in - guard let self, !self.isClosing, self.sessionStore.getSession().account == session.account, - self.serverUrl == folderUrl else { + guard let self, self.folderBrowserID == browserID, !self.isClosing else { task.cancel() return } - self.dataSourceTask = task + self.folderRequests[folder.serverUrl] = task } } - guard !Task.isCancelled, !isClosing, sessionStore.getSession().account == session.account, - serverUrl == folderUrl else { return } - if result.error == .success { - metadataFolder = result.metadataFolder - await reloadData() - } else { - showAlert(description: result.error.errorDescription) + guard isCurrentBrowser(browserID, account: session.account) else { return } + guard result.error == .success else { + folderLoadErrors[folder.serverUrl] = result.error.errorDescription + return } - } -} - -extension NCShareViewModel { - func openFolder(_ folder: NCShareFolderItem) { - guard isAuthenticated, !isClosing, !isSwitchingAccount, !isLoading, !isPreparingUpload else { return } - let origin = serverUrl - Task { - guard let presenter else { return } - guard let tblAccount = sessionStore.getTblAccoun(), - let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(folder.id) else { - return self.showAlert(description: "_invalid_url_") - } - guard !Task.isCancelled, !isClosing, !isSwitchingAccount, - metadata.account == sessionStore.getSession().account, metadata.serverUrl == origin else { return } - 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) - guard !Task.isCancelled, !isClosing, !isSwitchingAccount, - sessionStore.getSession().account == tblAccount.account, self.serverUrl == origin else { return } - - 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: presenter) - return + let children = await cachedFolderChildren(at: folder.serverUrl, parent: result.metadataFolder, session: session) + guard isCurrentBrowser(browserID, account: session.account) else { return } + folderChildren[folder.serverUrl] = children + loadedFolderURLs.insert(folder.serverUrl) + if let metadata = result.metadataFolder { + let updated = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) + if folders.first?.serverUrl == folder.serverUrl { folders = [updated] } + if selectedFolder?.serverUrl == folder.serverUrl { + metadataFolder = metadata + selectedFolder = updated } + } + } - self.serverUrl = serverUrl - self.setNavigationBar(navigationTitle: metadata.fileNameView) + private func cachedFolderChildren(at url: String, parent: tableMetadata?, session: NCSession.Session) async -> [NCShareFolderItem] { + let layout = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, 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 metadatas.map { metadata in + let childUrl = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) + return folderSnapshot(metadata, url: childUrl, parent: parent, session: session) + } + } - await self.reloadData() - await self.loadFolder() + 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) + } + + 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 index cf7314fc25..5b424981df 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -211,6 +211,7 @@ extension NCShareViewModel { } if error == .success { + recordRecentDestination() banner?.update(payload: LucidBannerPayload.Update(stage: .success, horizontalLayout: .centered(width: 100)), for: self.token) } else { banner?.update(payload: LucidBannerPayload.Update(subtitle: error?.errorDescription, stage: .error), for: self.token) diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 76beccae0c..258231645f 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -9,6 +9,19 @@ import LucidBanner @MainActor final class NCShareViewModel: NSObject, ObservableObject { @Published var folders: [NCShareFolderItem] = [] + @Published var recentFolders: [NCShareFolderItem] = [] + @Published var selectedFolder: NCShareFolderItem? + @Published var folderChildren: [String: [NCShareFolderItem]] = [:] + @Published var expandedFolderURLs: Set = [] + @Published var loadingFolderURLs: Set = [] + @Published var loadedFolderURLs: Set = [] + @Published var folderLoadErrors: [String: String] = [:] + var folderTasks: [String: Task] = [:] + var folderRequests: [String: URLSessionTask] = [:] + var folderSelectionTask: Task? + var folderBrowserID = UUID() + lazy var recentFoldersStore = NCShareRecentFoldersStore( + defaults: UserDefaults(suiteName: NCBrandOptions.shared.capabilitiesGroup) ?? .standard) @Published var fileNames: [String] = [] @Published var isLoading = false @Published var isAvailable = false @@ -32,7 +45,6 @@ final class NCShareViewModel: NSObject, ObservableObject { var accountPickerTask: Task? var accountChangeTask: Task? var accountChangeID = UUID() - @Published var canGoBack = false @Published private(set) var isAuthenticated = false weak var presenter: UIViewController? @@ -184,6 +196,9 @@ final class NCShareViewModel: NSObject, ObservableObject { accountPickerTask?.cancel() accountChangeTask?.cancel() dataSourceTask?.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) } if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } @@ -224,9 +239,8 @@ final class NCShareViewModel: NSObject, ObservableObject { dataSourceTask = nil metadataFolder = nil isAvailable = false - folders = [] + resetFolderBrowser() accountName = "" - canGoBack = false navigationTitle = NCBrandOptions.shared.brand } @@ -245,6 +259,9 @@ final class NCShareViewModel: NSObject, ObservableObject { accountPickerTask?.cancel() accountChangeTask?.cancel() dataSourceTask?.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) } if let error { requestContext?.cancelRequest(withError: error) @@ -266,18 +283,6 @@ final class NCShareViewModel: NSObject, ObservableObject { self.navigationTitle = navigationTitle guard let account = sessionStore.getTblAccoun() else { return } accountName = account.alias.isEmpty ? account.displayName : account.alias - canGoBack = serverUrl != utilityFileSystem.getHomeServer(session: sessionStore.getSession()) - } - - func goBack() { - guard canGoBack, !isLoading, !isPreparingUpload, !isSwitchingAccount else { return } - serverUrl = (serverUrl as NSString).deletingLastPathComponent - let home = utilityFileSystem.getHomeServer(session: sessionStore.getSession()) - setNavigationBar(navigationTitle: serverUrl == home ? NCBrandOptions.shared.brand : (serverUrl as NSString).lastPathComponent) - Task { - await reloadData() - await loadFolder() - } } func setCommandView() { @@ -286,11 +291,13 @@ final class NCShareViewModel: NSObject, ObservableObject { func createFolder() { let session = sessionStore.getSession() - guard let capabilities = NCNetworking.shared.capabilities[session.account] else { return } - let alert = UIAlertController.createFolderWith(serverUrl: serverUrl, session: session, capabilities: capabilities) { [weak self] error in + guard let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl), let capabilities = NCNetworking.shared.capabilities[session.account] else { return } + let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in guard let self else { return } + guard !self.isClosing, self.sessionStore.getSession().account == session.account else { return } if error == .success { - Task { await self.loadFolder() } + self.expandedFolderURLs.insert(folder.serverUrl) + self.requestFolderChildren(folder, refresh: true) } else { self.showAlert(title: "_error_createsubfolders_upload_", description: error.errorDescription) } @@ -386,9 +393,19 @@ extension NCShareViewModel { /// A UI snapshot, so SwiftUI does not retain live Realm objects. struct NCShareFolderItem: Identifiable { - let id: String + var id: String { serverUrl } + let ocId: String let name: String + let serverUrl: String + let path: String let image: UIImage? + let isEncrypted: Bool +} + +struct NCShareFolderRow: Identifiable { + var id: String { folder.id } + let folder: NCShareFolderItem + let depth: Int } enum NCShareRequestError: Error { @@ -435,11 +452,18 @@ extension NCShareViewModel { model.isAvailable = true model.isAuthenticated = true model.accountName = "Personal" - model.folders = [ - NCShareFolderItem(id: "documents", name: "Documents", image: UIImage(systemName: "folder.fill")), - NCShareFolderItem(id: "photos", name: "Photos", image: UIImage(systemName: "folder.fill")), - NCShareFolderItem(id: "shared", name: "Shared with the team", image: UIImage(systemName: "person.2.fill")) - ] + 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) + 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.serverUrl = documents.serverUrl + model.selectedFolder = documents + model.folders = [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), 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/View/NCShareView.swift b/Share/View/NCShareView.swift index d2dc7f3cc6..81c9f1cf2f 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -14,6 +14,10 @@ struct NCShareView: View { if model.isAvailable && model.isAuthenticated { VStack(spacing: 0) { folderList + if model.isLoading { + ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + .padding(.vertical, 8) + } if model.isPreparingInput { ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) .padding() @@ -47,12 +51,6 @@ struct NCShareView: View { .toolbar { ToolbarItemGroup(placement: .topBarLeading) { if model.isAvailable && model.isAuthenticated { - if model.canGoBack { - Button(action: model.goBack) { - Label(NSLocalizedString("_back_", comment: ""), systemImage: "chevron.left") - } - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) - } Button(action: model.showAccountPicker) { Label(model.accountName, systemImage: "person.crop.circle") .lineLimit(1) @@ -68,47 +66,135 @@ struct NCShareView: View { } private var folderList: some View { - List(model.folders) { folder in + List { + Section(NSLocalizedString("_share_recent_folders_", comment: "")) { + if model.recentFolders.isEmpty { + Text(NSLocalizedString("_share_recent_folders_empty_", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(model.recentFolders) { folder in + folderRow(folder, depth: nil) + } + } + } + Section { + ForEach(model.visibleFolderRows) { row in + folderRow(row.folder, depth: row.depth) + if model.expandedFolderURLs.contains(row.folder.serverUrl) { + if let error = model.folderLoadErrors[row.folder.serverUrl] { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: error) + .font(.caption) + .foregroundStyle(.secondary) + Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } + } + .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) + } else if model.loadedFolderURLs.contains(row.folder.serverUrl), + model.folderChildren[row.folder.serverUrl]?.isEmpty == true { + Text(NSLocalizedString("_files_no_folders_", comment: "")) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) + } + } + } + } header: { + allFoldersHeader + } + } + .listStyle(.insetGrouped) + .listSectionSpacing(8) + .environment(\.defaultMinListRowHeight, 44) + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + } + + private var allFoldersHeader: some View { + HStack { + Text(NSLocalizedString("_share_all_folders_", comment: "")) + Spacer() + Button(action: model.createFolder) { + Label(NSLocalizedString("_create_folder_", comment: ""), systemImage: "folder.badge.plus") + .labelStyle(.iconOnly) + .font(.body) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .disabled(model.isPreparingInput || !model.inputErrorMessage.isEmpty || + (model.selectedFolder.map { model.loadingFolderURLs.contains($0.serverUrl) } ?? true)) + } + .textCase(nil) + } + + private func folderRow(_ folder: NCShareFolderItem, depth: Int?) -> some View { + HStack(spacing: 4) { + if let depth { + Button { + model.toggleFolder(folder) + } label: { + Group { + if model.loadingFolderURLs.contains(folder.serverUrl) { + ProgressView() + } else { + Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") + } + } + .frame(width: 44, height: 44) + .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) + } Button { - model.openFolder(folder) + model.selectFolder(folder) } label: { - HStack(spacing: 12) { + HStack(spacing: 8) { if let image = folder.image { Image(uiImage: image) + .renderingMode(.original) .resizable() .scaledToFit() - .frame(width: 36, height: 36) + .frame(width: 22, height: 22) + } + VStack(alignment: .leading, spacing: 1) { + Text(verbatim: folder.name) + .font(.subheadline) + .foregroundStyle(.primary) + .lineLimit(1) + if depth == nil { + Text(verbatim: folder.path) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Spacer(minLength: 4) + if model.selectedFolder?.serverUrl == folder.serverUrl { + Image(systemName: "checkmark") + .accessibilityHidden(true) } - Text(verbatim: folder.name) - .foregroundStyle(.primary) - .lineLimit(2) - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - .accessibilityHidden(true) } - .padding(.vertical, 4) + .frame(minHeight: 44) .contentShape(Rectangle()) } - .buttonStyle(.plain) - } - .listStyle(.plain) - .overlay { - if model.folders.isEmpty { - if model.isLoading { - ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) - } else { - ContentUnavailableView(NSLocalizedString("_files_no_folders_", comment: ""), systemImage: "folder") - } - } + .buttonStyle(.borderless) + .accessibilityAddTraits(model.selectedFolder?.serverUrl == folder.serverUrl ? .isSelected : []) } - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) + .listRowBackground(model.selectedFolder?.serverUrl == folder.serverUrl ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemGroupedBackground)) } private var commands: some View { VStack(spacing: 12) { - Button(action: model.createFolder) { - Label(NSLocalizedString("_create_folder_", comment: ""), systemImage: "folder.badge.plus") + if let folder = model.selectedFolder { + Label(folder.path, systemImage: "folder") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) } List(model.fileNames, id: \.self) { fileName in @@ -143,11 +229,11 @@ struct NCShareView: View { .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 64) Button(action: model.upload) { - Text(NSLocalizedString("_upload_", comment: "") + " \(model.fileNames.count) " + NSLocalizedString("_files_", comment: "")) + Text(String(format: NSLocalizedString("_share_upload_to_", comment: ""), model.selectedFolder?.name ?? "")) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) - .disabled(model.fileNames.isEmpty || model.isLoading || model.isShowingAccounts) + .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading || model.isShowingAccounts) } .padding() .background(Color(uiColor: .secondarySystemBackground)) @@ -163,7 +249,9 @@ struct NCShareView: View { #Preview("No folders") { let model = NCShareViewModel.preview() - model.folders = [] + model.folderChildren = [:] + model.recentFolders = [] + model.loadedFolderURLs = Set(model.folders.map(\.serverUrl)) return NCShareView(model: model) .allowsHitTesting(false) } 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/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 28bca846df..3a5815b4fb 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -901,3 +901,14 @@ "_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_home_folder_" = "My files"; +"_share_recent_folders_" = "Recent folders"; +"_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_upload_to_" = "Upload to %@"; From 7395bbdd0af115afc6a9734c74e3f2b4486d17f4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 28 Oct 2026 18:20:15 +0100 Subject: [PATCH 04/42] fix: load share extension capabilities and improve text import fallbacks Require server capabilities before browsing or uploading, with loading states and retry support. Fall back to usable text when other shared representations fail, including attributed text, and add import regression tests. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 67 ++++++++++++++++++- Share/Model/NCShareViewModel+Folders.swift | 7 +- Share/Model/NCShareViewModel+Upload.swift | 9 ++- Share/Model/NCShareViewModel.swift | 36 +++++++++- Share/NCShareLoaderItemProvider.swift | 50 +++++++++++++- Share/NCShareSessionStore.swift | 4 -- Share/View/NCShareView.swift | 37 +++++++++- .../NCShareLoaderItemProviderTests.swift | 45 +++++++++++++ .../en.lproj/Localizable.strings | 5 ++ 9 files changed, 241 insertions(+), 19 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 0684aaa4c9..6e82773eef 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -91,7 +91,72 @@ extension NCShareViewModel { serverUrl = utilityFileSystem.getHomeServer(session: session) setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) showAccountView() - await initializeFolderBrowser() + await loadServerCapabilities(account: account, changeID: changeID) + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } + if serverCapabilities != nil { await initializeFolderBrowser() } + } + } + + func requireCapabilities(account: String) throws -> NKCapabilities.Capabilities { + guard account == sessionStore.getSession().account else { throw CancellationError() } + guard !isLoadingCapabilities, let serverCapabilities else { + throw NKError(errorCode: NCGlobal.shared.errorInternalError, + errorDescription: NSLocalizedString("_share_capabilities_unavailable_", comment: "")) + } + return serverCapabilities + } + + func retryCapabilities() { + guard 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 folders.isEmpty { await initializeFolderBrowser() } + } + } + + 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 { + 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 index 4f7774eecb..99d1d055e7 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -112,7 +112,10 @@ extension NCShareViewModel { } let selected = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) guard canAccessFolder(selected) else { return } - let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) + guard let capabilities = serverCapabilities, !isLoadingCapabilities else { + showAlert(description: "_share_capabilities_unavailable_") + return + } guard isCurrentBrowser(browserID, account: session.account), isAuthenticated else { return } let home = utilityFileSystem.getHomeServer(session: session) if folder.serverUrl != home, @@ -136,7 +139,7 @@ extension NCShareViewModel { } private var canUseFolderBrowser: Bool { - isAuthenticated && !isClosing && !isSwitchingAccount && !isLoading && !isPreparingUpload && !isLoadingAccounts + isAuthenticated && !isClosing && !isSwitchingAccount && !isLoading && !isPreparingUpload && !isLoadingAccounts && !isLoadingCapabilities && serverCapabilities != nil } private func canAccessFolder(_ folder: NCShareFolderItem) -> Bool { diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 5b424981df..ea0b372b12 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -64,9 +64,8 @@ extension NCShareViewModel { let session = sessionStore.getSession() let destination = serverUrl let sourceNames = filesName - guard !sourceNames.isEmpty, let capabilities = NCNetworking.shared.capabilities[session.account] else { - throw NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: NSLocalizedString("_share_conflict_read_failed_", comment: "")) - } + guard !sourceNames.isEmpty else { throw NCShareRequestError.noFiles } + let capabilities = try requireCapabilities(account: session.account) let renamer = FileAutoRenamer(capabilities: capabilities) let requestedNames = try sourceNames.map { name in let candidate = renamer.rename(filename: name).trimmingCharacters(in: .whitespacesAndNewlines) @@ -130,8 +129,8 @@ extension NCShareViewModel { private func stageAndUpload(plan: NCShareUploadConfirmation, replacing: Bool) async throws { try Task.checkCancellation() let session = sessionStore.getSession() - guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl, - let capabilities = NCNetworking.shared.capabilities[session.account] else { throw CancellationError() } + guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl else { throw CancellationError() } + let capabilities = try requireCapabilities(account: session.account) uploadMetadata.removeAll() replacementETags.removeAll() counterUploaded = 0 diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 258231645f..35b7c4e2d0 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -41,6 +41,11 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var isShowingAccounts = false @Published var isLoadingAccounts = false @Published var isSwitchingAccount = false + @Published var serverCapabilities: NKCapabilities.Capabilities? + @Published var isLoadingCapabilities = false + @Published var capabilitiesErrorMessage = "" + var capabilitiesTask: Task? + var capabilitiesRequest: URLSessionTask? var pendingAccountSelection: String? var accountPickerTask: Task? var accountChangeTask: Task? @@ -195,6 +200,8 @@ final class NCShareViewModel: NSObject, ObservableObject { uploadTask?.cancel() accountPickerTask?.cancel() accountChangeTask?.cancel() + capabilitiesTask?.cancel() + capabilitiesRequest?.cancel() dataSourceTask?.cancel() folderSelectionTask?.cancel() for task in folderTasks.values { task.cancel() } @@ -235,6 +242,11 @@ final class NCShareViewModel: NSObject, ObservableObject { } func clearAccountView() { + capabilitiesTask?.cancel() + capabilitiesRequest?.cancel() + serverCapabilities = nil + isLoadingCapabilities = false + capabilitiesErrorMessage = "" dataSourceTask?.cancel() dataSourceTask = nil metadataFolder = nil @@ -258,6 +270,8 @@ final class NCShareViewModel: NSObject, ObservableObject { uploadTask?.cancel() accountPickerTask?.cancel() accountChangeTask?.cancel() + capabilitiesTask?.cancel() + capabilitiesRequest?.cancel() dataSourceTask?.cancel() folderSelectionTask?.cancel() for task in folderTasks.values { task.cancel() } @@ -291,7 +305,14 @@ final class NCShareViewModel: NSObject, ObservableObject { func createFolder() { let session = sessionStore.getSession() - guard let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl), let capabilities = NCNetworking.shared.capabilities[session.account] else { return } + guard let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl) else { return } + let capabilities: NKCapabilities.Capabilities + do { + capabilities = try requireCapabilities(account: session.account) + } catch { + showAlert(description: error.localizedDescription) + return + } let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in guard let self else { return } guard !self.isClosing, self.sessionStore.getSession().account == session.account else { return } @@ -306,7 +327,11 @@ final class NCShareViewModel: NSObject, ObservableObject { } func showRenameFileDialog(named fileName: String, account: String) { - guard let capabilities = NCNetworking.shared.capabilities[account] else { + let capabilities: NKCapabilities.Capabilities + do { + capabilities = try requireCapabilities(account: account) + } catch { + showAlert(description: error.localizedDescription) return } let alert = UIAlertController.renameFile(fileName: fileName, capabilities: capabilities, account: account) { [self] newFileName in @@ -353,6 +378,12 @@ final class NCShareViewModel: NSObject, ObservableObject { func upload() { guard isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isLoading, !fileNames.isEmpty else { return } + do { + _ = try requireCapabilities(account: sessionStore.getSession().account) + } catch { + showAlert(description: error.localizedDescription) + return + } isPreparingUpload = true actionUpload() } @@ -450,6 +481,7 @@ extension NCShareViewModel { static func preview() -> NCShareViewModel { let model = NCShareViewModel() model.isAvailable = true + model.serverCapabilities = NKCapabilities.Capabilities() model.isAuthenticated = true model.accountName = "Personal" let home = "https://cloud.example.com/remote.php/dav/files/marino" diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift index f1a5275655..be7b69a16c 100644 --- a/Share/NCShareLoaderItemProvider.swift +++ b/Share/NCShareLoaderItemProvider.swift @@ -147,16 +147,40 @@ final class NCShareLoaderItemProvider { } } + try Task.checkCancellation() if provider.canLoadObject(ofClass: UIImage.self) { - return try await loadImage(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + 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) { - return try await loadLink(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + 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) { - return try await loadText(provider: provider, name: suggestedName, fallbackName: fallbackName, directory: directory) + 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 } @@ -232,6 +256,26 @@ final class NCShareLoaderItemProvider { } } + /// 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 + guard !pending.isCancelled else { return } + do { + if let error { throw error } + guard let text = object as? NSAttributedString else { throw CocoaError(.fileReadCorruptFile) } + let destination = destination(name: name ?? fallbackName, type: .utf8PlainText, directory: directory) + try Data(text.string.utf8).write(to: destination, options: .atomic) + pending.finish(.success(destination.lastPathComponent)) + } catch { + pending.finish(.failure(error)) + } + } + 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 diff --git a/Share/NCShareSessionStore.swift b/Share/NCShareSessionStore.swift index 7807f4be00..238d9de649 100644 --- a/Share/NCShareSessionStore.swift +++ b/Share/NCShareSessionStore.swift @@ -13,12 +13,8 @@ final class NCShareSessionStore { /// 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 } - let capabilities = await NCManageDatabase.shared.getCapabilities(account: account) guard !Task.isCancelled else { return nil } tblAccount = selected - if let capabilities { - NCBrandColor.shared.settingThemingColor(account: account, capabilities: capabilities) - } return getSession() } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 81c9f1cf2f..e09cbbeb16 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -13,6 +13,7 @@ struct NCShareView: View { Group { if model.isAvailable && model.isAuthenticated { VStack(spacing: 0) { + capabilitiesStatus folderList if model.isLoading { ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) @@ -65,6 +66,23 @@ struct NCShareView: View { } } + @ViewBuilder + private var capabilitiesStatus: some View { + if model.isLoadingCapabilities { + ProgressView(NSLocalizedString("_share_capabilities_loading_", comment: "")) + .padding() + } else if !model.capabilitiesErrorMessage.isEmpty { + VStack(spacing: 8) { + Text(verbatim: model.capabilitiesErrorMessage) + .font(.subheadline) + .multilineTextAlignment(.center) + Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryCapabilities) + .disabled(model.isSwitchingAccount) + } + .padding() + } + } + private var folderList: some View { List { Section(NSLocalizedString("_share_recent_folders_", comment: "")) { @@ -106,7 +124,7 @@ struct NCShareView: View { .listStyle(.insetGrouped) .listSectionSpacing(8) .environment(\.defaultMinListRowHeight, 44) - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts || model.isLoadingCapabilities || model.serverCapabilities == nil) } private var allFoldersHeader: some View { @@ -237,7 +255,7 @@ struct NCShareView: View { } .padding() .background(Color(uiColor: .secondarySystemBackground)) - .disabled(model.isPreparingUpload || model.isPreparingInput || model.isSwitchingAccount || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + .disabled(model.isPreparingUpload || model.isPreparingInput || model.isSwitchingAccount || model.isLoadingAccounts || model.isLoadingCapabilities || model.serverCapabilities == nil || !model.inputErrorMessage.isEmpty) } } @@ -263,4 +281,19 @@ struct NCShareView: View { return NCShareView(model: model) .allowsHitTesting(false) } +#Preview("Capabilities unavailable") { + let model = NCShareViewModel.preview() + model.serverCapabilities = nil + model.capabilitiesErrorMessage = NSLocalizedString("_share_capabilities_unavailable_", comment: "") + return NCShareView(model: model) + .allowsHitTesting(false) +} + +#Preview("Loading capabilities") { + let model = NCShareViewModel.preview() + model.serverCapabilities = nil + model.isLoadingCapabilities = true + return NCShareView(model: model) + .allowsHitTesting(false) +} #endif diff --git a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift index 285ccc3e9c..e1717198db 100644 --- a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift +++ b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import Foundation +import UIKit import Testing import UniformTypeIdentifiers @testable import Nextcloud @@ -74,6 +75,50 @@ struct NCShareLoaderItemProviderTests { #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 item = NSItemProvider(object: attributed) + item.suggestedName = "Note" + + 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) + if file.pathExtension == "txt" { + #expect(try String(contentsOf: file, encoding: .utf8) == text) + } else { + let restored = try NSAttributedString(url: file, options: [:], documentAttributes: nil) + #expect(restored.string == text) + } + } + @Test("An empty file is valid input") func emptyFile() async throws { let directory = temporaryDirectory() diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 3a5815b4fb..48f450ed88 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -912,3 +912,8 @@ "_share_retry_folder_" = "Retry loading folders"; "_share_folder_unavailable_" = "This folder is no longer available."; "_share_upload_to_" = "Upload to %@"; + +/* 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"; From 5d5dc3ccbfba243412085228494d881ac0e00c42 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 09:16:34 +0100 Subject: [PATCH 05/42] refactor: simplify share extension capability handling Centralize account readiness checks and pass capabilities explicitly through upload preparation and staging. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 9 ------- Share/Model/NCShareViewModel+Folders.swift | 8 ++---- Share/Model/NCShareViewModel+Upload.swift | 16 ++++++------ Share/Model/NCShareViewModel.swift | 27 +++++---------------- Share/View/NCShareView.swift | 4 +-- 5 files changed, 17 insertions(+), 47 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 6e82773eef..48c646aaf1 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -97,15 +97,6 @@ extension NCShareViewModel { } } - func requireCapabilities(account: String) throws -> NKCapabilities.Capabilities { - guard account == sessionStore.getSession().account else { throw CancellationError() } - guard !isLoadingCapabilities, let serverCapabilities else { - throw NKError(errorCode: NCGlobal.shared.errorInternalError, - errorDescription: NSLocalizedString("_share_capabilities_unavailable_", comment: "")) - } - return serverCapabilities - } - func retryCapabilities() { guard isAuthenticated, !isClosing, !isLoadingCapabilities, !isSwitchingAccount, !isPreparingUpload else { return } let account = sessionStore.getSession().account diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 99d1d055e7..8eca13c513 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -93,7 +93,7 @@ extension NCShareViewModel { } func selectFolder(_ folder: NCShareFolderItem) { - guard canUseFolderBrowser, canAccessFolder(folder) else { return } + guard canUseFolderBrowser, let capabilities = serverCapabilities, canAccessFolder(folder) else { return } let session = sessionStore.getSession() let browserID = folderBrowserID isLoading = true @@ -112,10 +112,6 @@ extension NCShareViewModel { } let selected = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) guard canAccessFolder(selected) else { return } - guard let capabilities = serverCapabilities, !isLoadingCapabilities else { - showAlert(description: "_share_capabilities_unavailable_") - return - } guard isCurrentBrowser(browserID, account: session.account), isAuthenticated else { return } let home = utilityFileSystem.getHomeServer(session: session) if folder.serverUrl != home, @@ -139,7 +135,7 @@ extension NCShareViewModel { } private var canUseFolderBrowser: Bool { - isAuthenticated && !isClosing && !isSwitchingAccount && !isLoading && !isPreparingUpload && !isLoadingAccounts && !isLoadingCapabilities && serverCapabilities != nil + isAuthenticated && !isClosing && isAccountReady && !isLoading && !isPreparingUpload && !isLoadingAccounts } private func canAccessFolder(_ folder: NCShareFolderItem) -> Bool { diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index ea0b372b12..3a9e3413ce 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -12,18 +12,18 @@ import LucidBanner // MARK: - Upload extension NCShareViewModel { - func actionUpload() { + func actionUpload(capabilities: NKCapabilities.Capabilities) { uploadTask = Task { @MainActor in defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } do { - let plan = try await prepareUploadPlan() + let plan = try await prepareUploadPlan(capabilities: capabilities) try Task.checkCancellation() guard !isClosing, isAuthenticated else { return } if plan.requiresConfirmation { pendingUploadConfirmation = plan uploadConfirmation = plan } else { - try await stageAndUpload(plan: plan, replacing: false) + try await stageAndUpload(plan: plan, replacing: false, capabilities: capabilities) } } catch is CancellationError { // The extension was closed; do not present another screen. @@ -44,14 +44,14 @@ extension NCShareViewModel { let choice = uploadChoice pendingUploadConfirmation = nil uploadChoice = nil - guard let choice, isAuthenticated, !isClosing else { + guard let choice, let capabilities = serverCapabilities, isAuthenticated, !isClosing else { isPreparingUpload = false return } uploadTask = Task { @MainActor in defer { isPreparingUpload = false } do { - try await stageAndUpload(plan: plan, replacing: choice == .replace) + try await stageAndUpload(plan: plan, replacing: choice == .replace, capabilities: capabilities) } catch is CancellationError { // No upload after cancellation or a destination change. } catch { @@ -60,12 +60,11 @@ extension NCShareViewModel { } } - private func prepareUploadPlan() async throws -> NCShareUploadConfirmation { + private func prepareUploadPlan(capabilities: NKCapabilities.Capabilities) async throws -> NCShareUploadConfirmation { let session = sessionStore.getSession() let destination = serverUrl let sourceNames = filesName guard !sourceNames.isEmpty else { throw NCShareRequestError.noFiles } - let capabilities = try requireCapabilities(account: session.account) let renamer = FileAutoRenamer(capabilities: capabilities) let requestedNames = try sourceNames.map { name in let candidate = renamer.rename(filename: name).trimmingCharacters(in: .whitespacesAndNewlines) @@ -126,11 +125,10 @@ extension NCShareViewModel { requiresConfirmation: allocated != requestedNames) } - private func stageAndUpload(plan: NCShareUploadConfirmation, replacing: Bool) async throws { + private func stageAndUpload(plan: NCShareUploadConfirmation, replacing: Bool, capabilities: NKCapabilities.Capabilities) async throws { try Task.checkCancellation() let session = sessionStore.getSession() guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl else { throw CancellationError() } - let capabilities = try requireCapabilities(account: session.account) uploadMetadata.removeAll() replacementETags.removeAll() counterUploaded = 0 diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 35b7c4e2d0..265c68d906 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -44,6 +44,8 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var serverCapabilities: NKCapabilities.Capabilities? @Published var isLoadingCapabilities = false @Published var capabilitiesErrorMessage = "" + var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount } + var capabilitiesTask: Task? var capabilitiesRequest: URLSessionTask? var pendingAccountSelection: String? @@ -306,13 +308,7 @@ final class NCShareViewModel: NSObject, ObservableObject { func createFolder() { let session = sessionStore.getSession() guard let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl) else { return } - let capabilities: NKCapabilities.Capabilities - do { - capabilities = try requireCapabilities(account: session.account) - } catch { - showAlert(description: error.localizedDescription) - return - } + guard let capabilities = serverCapabilities else { return } let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in guard let self else { return } guard !self.isClosing, self.sessionStore.getSession().account == session.account else { return } @@ -327,13 +323,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } func showRenameFileDialog(named fileName: String, account: String) { - let capabilities: NKCapabilities.Capabilities - do { - capabilities = try requireCapabilities(account: account) - } catch { - showAlert(description: error.localizedDescription) - return - } + guard let capabilities = serverCapabilities else { return } let alert = UIAlertController.renameFile(fileName: fileName, capabilities: capabilities, account: account) { [self] newFileName in renameFile(oldName: fileName, newName: newFileName, account: account) } @@ -378,14 +368,9 @@ final class NCShareViewModel: NSObject, ObservableObject { func upload() { guard isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isLoading, !fileNames.isEmpty else { return } - do { - _ = try requireCapabilities(account: sessionStore.getSession().account) - } catch { - showAlert(description: error.localizedDescription) - return - } + guard let capabilities = serverCapabilities else { return } isPreparingUpload = true - actionUpload() + actionUpload(capabilities: capabilities) } } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index e09cbbeb16..b14627260d 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -124,7 +124,7 @@ struct NCShareView: View { .listStyle(.insetGrouped) .listSectionSpacing(8) .environment(\.defaultMinListRowHeight, 44) - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts || model.isLoadingCapabilities || model.serverCapabilities == nil) + .disabled(model.isLoading || model.isPreparingUpload || !model.isAccountReady || model.isLoadingAccounts) } private var allFoldersHeader: some View { @@ -255,7 +255,7 @@ struct NCShareView: View { } .padding() .background(Color(uiColor: .secondarySystemBackground)) - .disabled(model.isPreparingUpload || model.isPreparingInput || model.isSwitchingAccount || model.isLoadingAccounts || model.isLoadingCapabilities || model.serverCapabilities == nil || !model.inputErrorMessage.isEmpty) + .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } } From 94dd633655d3a240105cb0eaa3de9e427623bc79 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 09:55:08 +0100 Subject: [PATCH 06/42] fix: refine share extension folder selection and upload layout Distinguish recent-folder selection from tree selection, streamline the folder and file lists, and show a pluralized file count on the upload button. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Folders.swift | 4 +- Share/Model/NCShareViewModel.swift | 1 + Share/View/NCShareView.swift | 142 +++++++++++------- .../en.lproj/Localizable.strings | 1 - .../en.lproj/Localizable.stringsdict | 16 ++ 5 files changed, 108 insertions(+), 56 deletions(-) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 8eca13c513..7eb29df2d0 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -30,6 +30,7 @@ extension NCShareViewModel { folders = [] recentFolders = [] selectedFolder = nil + isRecentFolderSelected = false folderChildren = [:] expandedFolderURLs = [] loadingFolderURLs = [] @@ -92,7 +93,7 @@ extension NCShareViewModel { } } - func selectFolder(_ folder: NCShareFolderItem) { + func selectFolder(_ folder: NCShareFolderItem, fromRecents: Bool = false) { guard canUseFolderBrowser, let capabilities = serverCapabilities, canAccessFolder(folder) else { return } let session = sessionStore.getSession() let browserID = folderBrowserID @@ -122,6 +123,7 @@ extension NCShareViewModel { serverUrl = folder.serverUrl metadataFolder = metadata selectedFolder = selected + isRecentFolderSelected = fromRecents setNavigationBar(navigationTitle: selected.name) } } diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 265c68d906..dc30982859 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -11,6 +11,7 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var folders: [NCShareFolderItem] = [] @Published var recentFolders: [NCShareFolderItem] = [] @Published var selectedFolder: NCShareFolderItem? + @Published var isRecentFolderSelected = false @Published var folderChildren: [String: [NCShareFolderItem]] = [:] @Published var expandedFolderURLs: Set = [] @Published var loadingFolderURLs: Set = [] diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index b14627260d..d02f6747d3 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -84,69 +84,86 @@ struct NCShareView: View { } private var folderList: some View { - List { - Section(NSLocalizedString("_share_recent_folders_", comment: "")) { - if model.recentFolders.isEmpty { - Text(NSLocalizedString("_share_recent_folders_empty_", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(model.recentFolders) { folder in - folderRow(folder, depth: nil) + VStack(spacing: 0) { + createFolderHeader + .padding(.horizontal, 16) + List { + Section { + if !model.recentFolders.isEmpty { + ForEach(model.recentFolders) { folder in + folderRow(folder, depth: nil) + } } - } - } - Section { - ForEach(model.visibleFolderRows) { row in - folderRow(row.folder, depth: row.depth) - if model.expandedFolderURLs.contains(row.folder.serverUrl) { - if let error = model.folderLoadErrors[row.folder.serverUrl] { - VStack(alignment: .leading, spacing: 6) { - Text(verbatim: error) + ForEach(model.visibleFolderRows) { row in + folderRow(row.folder, depth: row.depth) + .padding(.top, !model.recentFolders.isEmpty && row.id == model.visibleFolderRows.first?.id ? 12 : 0) + .overlay(alignment: .top) { + if !model.recentFolders.isEmpty, row.id == model.visibleFolderRows.first?.id { + Rectangle() + .fill(Color(uiColor: .separator)) + .frame(height: 1) + .padding(.top, 6) + .padding(.horizontal, -12) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + if model.expandedFolderURLs.contains(row.folder.serverUrl) { + if let error = model.folderLoadErrors[row.folder.serverUrl] { + VStack(alignment: .leading, spacing: 6) { + Text(verbatim: error) + .font(.caption) + .foregroundStyle(.secondary) + Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } + } + .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) + } else if model.loadedFolderURLs.contains(row.folder.serverUrl), + model.folderChildren[row.folder.serverUrl]?.isEmpty == true { + Text(NSLocalizedString("_files_no_folders_", comment: "")) .font(.caption) .foregroundStyle(.secondary) - Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } + .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) } - .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) - } else if model.loadedFolderURLs.contains(row.folder.serverUrl), - model.folderChildren[row.folder.serverUrl]?.isEmpty == true { - Text(NSLocalizedString("_files_no_folders_", comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) } } } - } header: { - allFoldersHeader } + .listStyle(.plain) + .contentMargins(.vertical, 0, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 44) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) } - .listStyle(.insetGrouped) - .listSectionSpacing(8) - .environment(\.defaultMinListRowHeight, 44) + .frame(maxHeight: .infinity) + .background(Color(uiColor: .systemGroupedBackground)) .disabled(model.isLoading || model.isPreparingUpload || !model.isAccountReady || model.isLoadingAccounts) } - private var allFoldersHeader: some View { + private var createFolderHeader: some View { HStack { - Text(NSLocalizedString("_share_all_folders_", comment: "")) Spacer() Button(action: model.createFolder) { - Label(NSLocalizedString("_create_folder_", comment: ""), systemImage: "folder.badge.plus") - .labelStyle(.iconOnly) - .font(.body) - .frame(minWidth: 44, minHeight: 44) - .contentShape(Rectangle()) + HStack(spacing: 8) { + Text(NSLocalizedString("_create_folder_", comment: "")) + Image(systemName: "folder.badge.plus") + } + .font(.subheadline) + .frame(minHeight: 44) + .contentShape(Rectangle()) } .buttonStyle(.borderless) .disabled(model.isPreparingInput || !model.inputErrorMessage.isEmpty || (model.selectedFolder.map { model.loadingFolderURLs.contains($0.serverUrl) } ?? true)) } .textCase(nil) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } private func folderRow(_ folder: NCShareFolderItem, depth: Int?) -> some View { - HStack(spacing: 4) { + let isSelected = model.selectedFolder?.serverUrl == folder.serverUrl && model.isRecentFolderSelected == (depth == nil) + return HStack(spacing: 4) { if let depth { Button { model.toggleFolder(folder) @@ -165,9 +182,15 @@ struct NCShareView: View { .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") + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(width: 44, height: 44) + .accessibilityHidden(true) } Button { - model.selectFolder(folder) + model.selectFolder(folder, fromRecents: depth == nil) } label: { HStack(spacing: 8) { if let image = folder.image { @@ -191,7 +214,7 @@ struct NCShareView: View { } } Spacer(minLength: 4) - if model.selectedFolder?.serverUrl == folder.serverUrl { + if isSelected { Image(systemName: "checkmark") .accessibilityHidden(true) } @@ -200,21 +223,15 @@ struct NCShareView: View { .contentShape(Rectangle()) } .buttonStyle(.borderless) - .accessibilityAddTraits(model.selectedFolder?.serverUrl == folder.serverUrl ? .isSelected : []) + .accessibilityAddTraits(isSelected ? .isSelected : []) } + .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) - .listRowBackground(model.selectedFolder?.serverUrl == folder.serverUrl ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemGroupedBackground)) + .listRowBackground(isSelected ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemGroupedBackground)) } private var commands: some View { VStack(spacing: 12) { - if let folder = model.selectedFolder { - Label(folder.path, systemImage: "folder") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - .frame(maxWidth: .infinity, alignment: .leading) - } List(model.fileNames, id: \.self) { fileName in HStack { Button { @@ -224,6 +241,7 @@ struct NCShareView: View { Text(verbatim: fileName).lineLimit(2) } icon: { Image(systemName: "doc") + .frame(width: 22, height: 22) } .foregroundStyle(.primary) } @@ -242,19 +260,35 @@ struct NCShareView: View { } .accessibilityLabel(Text(verbatim: fileName)) } + .font(.subheadline) + .frame(minHeight: 44) + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) + .listRowSeparator(.hidden) } .listStyle(.plain) - .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 64) + .contentMargins(.vertical, 0, for: .scrollContent) + .scrollContentBackground(.hidden) + .environment(\.defaultMinListRowHeight, 44) + .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 44) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) Button(action: model.upload) { - Text(String(format: NSLocalizedString("_share_upload_to_", comment: ""), model.selectedFolder?.name ?? "")) + Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .padding(.horizontal, 16) .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading || model.isShowingAccounts) } - .padding() - .background(Color(uiColor: .secondarySystemBackground)) + .padding(.top, 10) + .padding(.bottom, 16) + .background(Color(uiColor: .systemGroupedBackground)) .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 48f450ed88..5091f6d40a 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -911,7 +911,6 @@ "_share_collapse_folder_" = "Collapse %@"; "_share_retry_folder_" = "Retry loading folders"; "_share_folder_unavailable_" = "This folder is no longer available."; -"_share_upload_to_" = "Upload to %@"; /* Share extension capabilities */ "_share_capabilities_loading_" = "Loading server capabilities…"; 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 From 2b5538867aec80cbf7bb8a8c201bfa1feda0501c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 10:12:39 +0100 Subject: [PATCH 07/42] fix: make cached share destinations usable during refresh Refresh folders asynchronously and consolidate share status indicators into a single prioritized view. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Folders.swift | 5 ++- Share/View/NCShareView.swift | 37 +++++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 7eb29df2d0..e789740656 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -58,12 +58,11 @@ extension NCShareViewModel { image: item.isEncrypted ? NCImageCache.shared.getFolderEncrypted(account: session.account) : NCImageCache.shared.getFolder(account: session.account), isEncrypted: item.isEncrypted) } - isLoading = true - defer { if folderBrowserID == browserID { isLoading = false } } let cached = await cachedFolderChildren(at: home, parent: nil, session: session) guard isCurrentBrowser(browserID, account: session.account) else { return } folderChildren[home] = cached - await loadFolderChildren(root, browserID: browserID) + // Make cached destinations usable without waiting for the server refresh. + requestFolderChildren(root) } func toggleFolder(_ folder: NCShareFolderItem) { diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index d02f6747d3..1d37d8ab50 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -13,24 +13,13 @@ struct NCShareView: View { Group { if model.isAvailable && model.isAuthenticated { VStack(spacing: 0) { - capabilitiesStatus + statusView folderList - if model.isLoading { - ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) - .padding(.vertical, 8) - } - if model.isPreparingInput { - ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) - .padding() - } - if model.isPreparingUpload { - ProgressView(NSLocalizedString("_share_conflict_checking_", comment: "")) - .padding() - } commands } } else if model.isSwitchingAccount && model.isAuthenticated { - ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + ProgressView() + .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) } else { Color(uiColor: .systemBackground) } @@ -67,11 +56,8 @@ struct NCShareView: View { } @ViewBuilder - private var capabilitiesStatus: some View { - if model.isLoadingCapabilities { - ProgressView(NSLocalizedString("_share_capabilities_loading_", comment: "")) - .padding() - } else if !model.capabilitiesErrorMessage.isEmpty { + private var statusView: some View { + if !model.capabilitiesErrorMessage.isEmpty { VStack(spacing: 8) { Text(verbatim: model.capabilitiesErrorMessage) .font(.subheadline) @@ -80,6 +66,19 @@ struct NCShareView: View { .disabled(model.isSwitchingAccount) } .padding() + } else if model.isLoadingCapabilities { + ProgressView(NSLocalizedString("_share_capabilities_loading_", comment: "")) + .padding() + } else if model.isPreparingInput { + ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) + .padding() + } else if model.isPreparingUpload { + ProgressView(NSLocalizedString("_share_conflict_checking_", comment: "")) + .padding() + } else if model.isLoading { + ProgressView() + .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) + .padding() } } From 6ac18e1f73c814494868301a2232f55d8e40a922 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 10:27:49 +0100 Subject: [PATCH 08/42] feat: improve share extension account menu and file previews Replace the account picker sheet with a menu showing avatars and account aliases. Add cached Quick Look thumbnails with file-type icon fallbacks, and enlarge file and folder rows. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 14 ++-- Share/Model/NCShareViewModel+Accounts.swift | 34 ++++------ Share/Model/NCShareViewModel.swift | 75 ++++++++++++++++++--- Share/View/NCShareAccountMenu.swift | 66 ++++++++++++++++++ Share/View/NCShareAccountPickerView.swift | 60 ----------------- Share/View/NCShareView.swift | 49 ++++++++------ 6 files changed, 178 insertions(+), 120 deletions(-) create mode 100644 Share/View/NCShareAccountMenu.swift delete mode 100644 Share/View/NCShareAccountPickerView.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b9365fafc8..bf5a1a5340 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -13,14 +13,14 @@ AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; - AB1028260000000000080006 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.swift */; }; + AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.swift */; }; AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; AB1028260000000000080003 /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; AB1028260000000000080002 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; AB1028260000000000080001 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; AB1028260000000000080000 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; - AB1028260000000000060001 /* NCShareAccountPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountPickerView.swift */; }; + AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.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 */; }; AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; @@ -1315,7 +1315,7 @@ /* Begin PBXFileReference section */ AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStoreTests.swift; sourceTree = ""; }; AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStore.swift; sourceTree = ""; }; - AB1028260000000000060000 /* NCShareAccountPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountPickerView.swift; sourceTree = ""; }; + AB1028260000000000060000 /* NCShareAccountMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountMenu.swift; sourceTree = ""; }; AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadNamePlannerTests.swift; sourceTree = ""; }; AB1028260000000000050001 /* NCShareUploadConflictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadConflictView.swift; sourceTree = ""; }; AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLoaderItemProviderTests.swift; sourceTree = ""; }; @@ -2303,7 +2303,7 @@ isa = PBXGroup; children = ( AB1028260000000000020009 /* NCShareView.swift */, - AB1028260000000000060000 /* NCShareAccountPickerView.swift */, + AB1028260000000000060000 /* NCShareAccountMenu.swift */, AB1028260000000000050001 /* NCShareUploadConflictView.swift */, ); path = View; @@ -4982,7 +4982,7 @@ AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */, AB1028260000000000030009 /* NCShareView.swift in Sources */, AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */, - AB1028260000000000060001 /* NCShareAccountPickerView.swift in Sources */, + AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */, AB102826000000000003000A /* NCShareViewModel.swift in Sources */, F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */, F76B3CCF1EAE01BD00921AC9 /* NCBrand.swift in Sources */, @@ -5121,7 +5121,7 @@ AB1028260000000000080003 /* NCShareViewModel.swift in Sources */, AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */, AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */, - AB1028260000000000080006 /* NCShareAccountPickerView.swift in Sources */, + AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */, AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */, AB1028260000000000080008 /* NCShareView.swift in Sources */, F76BED77306D0000002E1442 /* NCLocalDatabase.swift in Sources */, @@ -6519,7 +6519,7 @@ "NCShareViewModel.swift", "NCShareLoaderItemProvider.swift", "NCShareSessionStore.swift", - "NCShareAccountPickerView.swift", + "NCShareAccountMenu.swift", "NCShareUploadConflictView.swift", "NCShareView.swift", ); diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 48c646aaf1..81a5abf6f7 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -6,11 +6,11 @@ import NextcloudKit import UIKit extension NCShareViewModel { - func showAccountPicker() { + func loadAccounts() { guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, - !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isShowingInputError else { return } + !isLoadingAccounts, !isShowingInputError else { return } isLoadingAccounts = true - accountPickerTask = Task { @MainActor in + accountsTask = Task { @MainActor in defer { isLoadingAccounts = false showInputErrorIfNeeded() @@ -24,37 +24,30 @@ extension NCShareViewModel { showAlert(description: "_no_active_account_") { self.cancel(with: .noAccount) } return } + accountAvatars = Dictionary(uniqueKeysWithValues: storedAccounts.map { account in + (account.account, utility.loadUserImage(for: account.user, displayName: account.displayName, urlBase: account.urlBase)) + }) accounts = storedAccounts.map { account in - NCShareAccountItem( + let name = account.displayName.isEmpty ? account.user : account.displayName + return NCShareAccountItem( id: account.account, - name: account.alias.isEmpty ? (account.displayName.isEmpty ? account.user : account.displayName) : account.alias, + name: account.alias.isEmpty ? name : name + " - " + account.alias, user: account.user, serverUrl: account.urlBase, isSelected: account.account == selected) }.sorted { $0.isSelected && !$1.isSelected } - if accounts.count > 1 { isShowingAccounts = true } } } func selectAccount(_ account: String) { guard isAuthenticated, !isClosing, !isPreparingUpload, !isSwitchingAccount, accounts.contains(where: { $0.id == account }) else { return } - pendingAccountSelection = account == sessionStore.getSession().account ? nil : account - isShowingAccounts = false + guard account != sessionStore.getSession().account else { return } + changeAccount(account) } - func dismissAccountPicker() { - accountPickerTask?.cancel() - pendingAccountSelection = nil - isShowingAccounts = false - } - - func accountPickerDismissed() { - showInputErrorIfNeeded() - let selected = pendingAccountSelection - pendingAccountSelection = nil - guard let selected, isAuthenticated, !isClosing, !isPreparingUpload else { return } - changeAccount(selected) + func cancelAccountLoading() { + accountsTask?.cancel() } func changeAccount(_ account: String) { @@ -91,6 +84,7 @@ extension NCShareViewModel { serverUrl = utilityFileSystem.getHomeServer(session: session) setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) showAccountView() + loadAccounts() await loadServerCapabilities(account: account, changeID: changeID) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } if serverCapabilities != nil { await initializeFolderBrowser() } diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index dc30982859..11ea58c2d0 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -5,6 +5,8 @@ import SwiftUI import NextcloudKit import LucidBanner +import QuickLookThumbnailing +import UniformTypeIdentifiers @MainActor final class NCShareViewModel: NSObject, ObservableObject { @@ -24,6 +26,9 @@ final class NCShareViewModel: NSObject, ObservableObject { 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 var isAvailable = false @Published var isPreparingUpload = false @@ -39,7 +44,7 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var navigationTitle = NCBrandOptions.shared.brand @Published var accountName = "" @Published var accounts: [NCShareAccountItem] = [] - @Published var isShowingAccounts = false + @Published var accountAvatars: [String: UIImage] = [:] @Published var isLoadingAccounts = false @Published var isSwitchingAccount = false @Published var serverCapabilities: NKCapabilities.Capabilities? @@ -49,8 +54,7 @@ final class NCShareViewModel: NSObject, ObservableObject { var capabilitiesTask: Task? var capabilitiesRequest: URLSessionTask? - var pendingAccountSelection: String? - var accountPickerTask: Task? + var accountsTask: Task? var accountChangeTask: Task? var accountChangeID = UUID() @Published private(set) var isAuthenticated = false @@ -106,14 +110,14 @@ final class NCShareViewModel: NSObject, ObservableObject { backgroundObserver = NotificationCenter.default.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor [weak self] in self?.dismissAccountPicker() } + Task { @MainActor [weak self] in self?.cancelAccountLoading() } } 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.dismissAccountPicker() + self.cancelAccountLoading() guard !self.maintenanceMode, self.validateAccount() else { return } self.resolveUploadConfirmation(nil) self.isPasscodeAuthenticated = false @@ -182,13 +186,58 @@ final class NCShareViewModel: NSObject, ObservableObject { } } + 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 showInputErrorIfNeeded() { // Do not present import errors over the passcode screen. - if isAuthenticated && !isShowingAccounts && !isLoadingAccounts && !inputErrorMessage.isEmpty { + if isAuthenticated && !isLoadingAccounts && !inputErrorMessage.isEmpty { isShowingInputError = true } } @@ -199,9 +248,10 @@ final class NCShareViewModel: NSObject, ObservableObject { } isolated deinit { + for request in thumbnailRequests.values { QLThumbnailGenerator.shared.cancel(request) } inputTask?.cancel() uploadTask?.cancel() - accountPickerTask?.cancel() + accountsTask?.cancel() accountChangeTask?.cancel() capabilitiesTask?.cancel() capabilitiesRequest?.cancel() @@ -265,13 +315,13 @@ final class NCShareViewModel: NSObject, ObservableObject { func cancel(with error: NCShareRequestError? = nil) { isClosing = true - dismissAccountPicker() + cancelAccountLoading() uploadConfirmation = nil pendingUploadConfirmation = nil uploadChoice = nil inputTask?.cancel() uploadTask?.cancel() - accountPickerTask?.cancel() + accountsTask?.cancel() accountChangeTask?.cancel() capabilitiesTask?.cancel() capabilitiesRequest?.cancel() @@ -299,7 +349,8 @@ final class NCShareViewModel: NSObject, ObservableObject { func setNavigationBar(navigationTitle: String) { self.navigationTitle = navigationTitle guard let account = sessionStore.getTblAccoun() else { return } - accountName = account.alias.isEmpty ? account.displayName : account.alias + let name = account.displayName.isEmpty ? account.user : account.displayName + accountName = account.alias.isEmpty ? name : name + " - " + account.alias } func setCommandView() { @@ -341,6 +392,7 @@ final class NCShareViewModel: NSObject, ObservableObject { return showAlert(title: "_single_file_conflict_title_", description: "'\(oldName)' -> '\(newName)'") } + discardThumbnail(named: oldName) filesName[fileIx] = newName } @@ -349,6 +401,7 @@ final class NCShareViewModel: NSObject, ObservableObject { guard let index = self.filesName.firstIndex(of: fileName) else { return showAlert(title: "_file_not_found_", description: fileName) } + discardThumbnail(named: fileName) try? FileManager.default.removeItem(at: inputFileURL(named: fileName)) self.filesName.remove(at: index) if self.filesName.isEmpty { @@ -368,7 +421,7 @@ final class NCShareViewModel: NSObject, ObservableObject { func upload() { guard isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, - !isSwitchingAccount, !isLoadingAccounts, !isShowingAccounts, !isLoading, !fileNames.isEmpty else { return } + !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } guard let capabilities = serverCapabilities else { return } isPreparingUpload = true actionUpload(capabilities: capabilities) diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift new file mode 100644 index 0000000000..11dfa975d7 --- /dev/null +++ b/Share/View/NCShareAccountMenu.swift @@ -0,0 +1,66 @@ +// 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 { + 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(for: account.id) + } + .tag(account.id) + } + } + .pickerStyle(.inline) + } label: { + Label { + Text(verbatim: model.accountName) + .lineLimit(1) + } icon: { + avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") + } + } + .disabled(model.accounts.count < 2) + + } + + @ViewBuilder + private func avatar(for account: String) -> some View { + if let image = model.accountAvatars[account] { + 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/NCShareAccountPickerView.swift b/Share/View/NCShareAccountPickerView.swift deleted file mode 100644 index 84e179dab9..0000000000 --- a/Share/View/NCShareAccountPickerView.swift +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import SwiftUI - -@MainActor -struct NCShareAccountPickerView: View { - @ObservedObject var model: NCShareViewModel - - var body: some View { - NavigationStack { - List(model.accounts) { account in - Button { - model.selectAccount(account.id) - } label: { - HStack(spacing: 12) { - Image(systemName: "person.crop.circle.fill") - .font(.title) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - Text(verbatim: account.name) - .foregroundStyle(.primary) - Text(verbatim: account.user) - .font(.subheadline) - .foregroundStyle(.secondary) - Text(verbatim: account.serverUrl) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if account.isSelected { - Image(systemName: "checkmark") - .accessibilityHidden(true) - } - } - .padding(.vertical, 4) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityAddTraits(account.isSelected ? .isSelected : []) - } - .navigationTitle(NSLocalizedString("_account_select_", comment: "")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(NSLocalizedString("_cancel_", comment: "")) { model.dismissAccountPicker() } - } - } - } - } -} - -#if DEBUG && SHARE_PREVIEWS -#Preview("Accounts") { - NCShareAccountPickerView(model: .preview()) - .allowsHitTesting(false) -} -#endif diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 1d37d8ab50..6b0428d515 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -33,18 +33,12 @@ struct NCShareView: View { .sheet(item: $model.uploadConfirmation, onDismiss: { model.uploadConfirmationDismissed() }) { confirmation in NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) } - .sheet(isPresented: $model.isShowingAccounts, onDismiss: { model.accountPickerDismissed() }) { - NCShareAccountPickerView(model: model) - } .navigationTitle(model.navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { if model.isAvailable && model.isAuthenticated { - Button(action: model.showAccountPicker) { - Label(model.accountName, systemImage: "person.crop.circle") - .lineLimit(1) - } + NCShareAccountMenu(model: model) .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) } } @@ -130,7 +124,7 @@ struct NCShareView: View { .listStyle(.plain) .contentMargins(.vertical, 0, for: .scrollContent) .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 44) + .environment(\.defaultMinListRowHeight, 50) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) @@ -149,7 +143,7 @@ struct NCShareView: View { Image(systemName: "folder.badge.plus") } .font(.subheadline) - .frame(minHeight: 44) + .frame(minHeight: 50) .contentShape(Rectangle()) } .buttonStyle(.borderless) @@ -174,7 +168,7 @@ struct NCShareView: View { Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") } } - .frame(width: 44, height: 44) + .frame(width: 50, height: 50) .contentShape(Rectangle()) } .buttonStyle(.borderless) @@ -185,7 +179,7 @@ struct NCShareView: View { Image(systemName: "clock") .font(.subheadline) .foregroundStyle(.secondary) - .frame(width: 44, height: 44) + .frame(width: 50, height: 50) .accessibilityHidden(true) } Button { @@ -197,11 +191,11 @@ struct NCShareView: View { .renderingMode(.original) .resizable() .scaledToFit() - .frame(width: 22, height: 22) + .frame(width: 26, height: 26) } VStack(alignment: .leading, spacing: 1) { Text(verbatim: folder.name) - .font(.subheadline) + .font(.body) .foregroundStyle(.primary) .lineLimit(1) if depth == nil { @@ -218,7 +212,7 @@ struct NCShareView: View { .accessibilityHidden(true) } } - .frame(minHeight: 44) + .frame(minHeight: 50) .contentShape(Rectangle()) } .buttonStyle(.borderless) @@ -239,8 +233,19 @@ struct NCShareView: View { Label { Text(verbatim: fileName).lineLimit(2) } icon: { - Image(systemName: "doc") - .frame(width: 22, height: 22) + Group { + if let thumbnail = model.fileThumbnails[fileName] { + Image(uiImage: thumbnail) + .resizable() + .scaledToFit() + } 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) } @@ -255,20 +260,20 @@ struct NCShareView: View { } } label: { Image(systemName: "ellipsis.circle") - .frame(minWidth: 44, minHeight: 44) + .frame(minWidth: 50, minHeight: 50) } .accessibilityLabel(Text(verbatim: fileName)) } - .font(.subheadline) - .frame(minHeight: 44) + .font(.body) + .frame(minHeight: 50) .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) .listRowSeparator(.hidden) } .listStyle(.plain) .contentMargins(.vertical, 0, for: .scrollContent) .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 44) - .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 44) + .environment(\.defaultMinListRowHeight, 50) + .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 50) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) @@ -283,7 +288,7 @@ struct NCShareView: View { .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 12)) .padding(.horizontal, 16) - .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading || model.isShowingAccounts) + .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading) } .padding(.top, 10) .padding(.bottom, 16) From 69c8c72eb28a811e70c19d288366dae37976e9a4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 10:48:45 +0100 Subject: [PATCH 09/42] fix: handle offline sharing and improve landscape layout Block network actions while offline and retry loading on reconnect. Adapt the Share extension to compact landscape layouts and show loading progress beside the account menu. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 3 +- Share/Model/NCShareViewModel+Folders.swift | 2 +- Share/Model/NCShareViewModel+Upload.swift | 1 + Share/Model/NCShareViewModel.swift | 40 ++++++++++++-- Share/View/NCShareAccountMenu.swift | 6 +- Share/View/NCShareView.swift | 55 +++++++++++++++---- .../en.lproj/Localizable.strings | 2 + 7 files changed, 89 insertions(+), 20 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 81a5abf6f7..a209a9600c 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -92,7 +92,7 @@ extension NCShareViewModel { } func retryCapabilities() { - guard isAuthenticated, !isClosing, !isLoadingCapabilities, !isSwitchingAccount, !isPreparingUpload else { return } + guard !isOffline, isAuthenticated, !isClosing, !isLoadingCapabilities, !isSwitchingAccount, !isPreparingUpload else { return } let account = sessionStore.getSession().account guard !account.isEmpty else { return } let changeID = accountChangeID @@ -112,6 +112,7 @@ extension NCShareViewModel { 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 { diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index e789740656..20ee392543 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -82,7 +82,7 @@ extension NCShareViewModel { } func requestFolderChildren(_ folder: NCShareFolderItem, refresh: Bool = false) { - guard !isClosing, !loadingFolderURLs.contains(folder.serverUrl), + guard !isOffline, !isClosing, !loadingFolderURLs.contains(folder.serverUrl), refresh || !loadedFolderURLs.contains(folder.serverUrl) else { return } let browserID = folderBrowserID // Mark synchronously, before scheduling, to prevent duplicate requests. diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 3a9e3413ce..dfe5b44e68 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -127,6 +127,7 @@ extension NCShareViewModel { 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 == serverUrl else { throw CancellationError() } uploadMetadata.removeAll() diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 11ea58c2d0..b406fb0704 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -5,7 +5,8 @@ import SwiftUI import NextcloudKit import LucidBanner -import QuickLookThumbnailing +import Network +@preconcurrency import QuickLookThumbnailing import UniformTypeIdentifiers @MainActor @@ -30,6 +31,8 @@ final class NCShareViewModel: NSObject, ObservableObject { 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 isAvailable = false @Published var isPreparingUpload = false @Published var uploadConfirmation: NCShareUploadConfirmation? @@ -50,7 +53,7 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var serverCapabilities: NKCapabilities.Capabilities? @Published var isLoadingCapabilities = false @Published var capabilitiesErrorMessage = "" - var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount } + var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount && !isOffline } var capabilitiesTask: Task? var capabilitiesRequest: URLSessionTask? @@ -95,6 +98,7 @@ final class NCShareViewModel: NSObject, ObservableObject { func start(context: NSExtensionContext?, presenter: UIViewController) { guard !hasStarted else { return } hasStarted = true + monitorConnection() self.requestContext = context self.presenter = presenter let versionNextcloudiOS = String(format: NCBrandOptions.shared.textCopyrightNextcloudiOS, utility.getVersionBuild()) @@ -186,6 +190,32 @@ final class NCShareViewModel: NSObject, ObservableObject { } } + private func monitorConnection() { + let monitor = NWPathMonitor() + connectionMonitor = monitor + 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")) + } + + 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 } @@ -248,6 +278,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } isolated deinit { + connectionMonitor?.cancel() for request in thumbnailRequests.values { QLThumbnailGenerator.shared.cancel(request) } inputTask?.cancel() uploadTask?.cancel() @@ -315,6 +346,7 @@ final class NCShareViewModel: NSObject, ObservableObject { func cancel(with error: NCShareRequestError? = nil) { isClosing = true + connectionMonitor?.cancel() cancelAccountLoading() uploadConfirmation = nil pendingUploadConfirmation = nil @@ -359,7 +391,7 @@ final class NCShareViewModel: NSObject, ObservableObject { func createFolder() { let session = sessionStore.getSession() - guard let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl) else { return } + guard isAccountReady, let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl) else { return } guard let capabilities = serverCapabilities else { return } let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in guard let self else { return } @@ -420,7 +452,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } func upload() { - guard isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, + guard isAccountReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } guard let capabilities = serverCapabilities else { return } isPreparingUpload = true diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift index 11dfa975d7..5af0ce6c9d 100644 --- a/Share/View/NCShareAccountMenu.swift +++ b/Share/View/NCShareAccountMenu.swift @@ -26,13 +26,13 @@ struct NCShareAccountMenu: View { } .pickerStyle(.inline) } label: { - Label { + HStack(spacing: 8) { + avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") Text(verbatim: model.accountName) .lineLimit(1) - } icon: { - avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") } } + .buttonStyle(.plain) .disabled(model.accounts.count < 2) } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 6b0428d515..22093a6a3f 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -14,8 +14,23 @@ struct NCShareView: View { if model.isAvailable && model.isAuthenticated { VStack(spacing: 0) { statusView - folderList - commands + GeometryReader { geometry in + if geometry.size.width >= 600 && geometry.size.height < 450 && geometry.size.width > geometry.size.height { + HStack(alignment: .top, spacing: 0) { + folderList + .frame(maxWidth: .infinity) + commands( + visibleRows: min(3, max(1, Int((geometry.size.height - 100) / 50))), + fillsHeight: true) + .frame(width: min(320, geometry.size.width * 0.38)) + } + } else { + VStack(spacing: 0) { + folderList + commands() + } + } + } } } else if model.isSwitchingAccount && model.isAuthenticated { ProgressView() @@ -38,8 +53,15 @@ struct NCShareView: View { .toolbar { ToolbarItemGroup(placement: .topBarLeading) { if model.isAvailable && model.isAuthenticated { - NCShareAccountMenu(model: model) - .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + HStack(spacing: 8) { + NCShareAccountMenu(model: model) + .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) + if model.isLoading || !model.loadingFolderURLs.isEmpty { + ProgressView() + .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) + .padding(.trailing, 8) + } + } } } ToolbarItem(placement: .topBarTrailing) { @@ -51,7 +73,15 @@ struct NCShareView: View { @ViewBuilder private var statusView: some View { - if !model.capabilitiesErrorMessage.isEmpty { + if model.isOffline { + VStack(spacing: 8) { + Label(NSLocalizedString("_share_online_required_", comment: ""), systemImage: "wifi.slash") + .font(.subheadline) + .multilineTextAlignment(.center) + Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryConnection) + } + .padding() + } else if !model.capabilitiesErrorMessage.isEmpty { VStack(spacing: 8) { Text(verbatim: model.capabilitiesErrorMessage) .font(.subheadline) @@ -69,10 +99,6 @@ struct NCShareView: View { } else if model.isPreparingUpload { ProgressView(NSLocalizedString("_share_conflict_checking_", comment: "")) .padding() - } else if model.isLoading { - ProgressView() - .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) - .padding() } } @@ -223,7 +249,7 @@ struct NCShareView: View { .listRowBackground(isSelected ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemGroupedBackground)) } - private var commands: some View { + private func commands(visibleRows: Int = 3, fillsHeight: Bool = false) -> some View { VStack(spacing: 12) { List(model.fileNames, id: \.self) { fileName in HStack { @@ -273,11 +299,13 @@ struct NCShareView: View { .contentMargins(.vertical, 0, for: .scrollContent) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 50) - .frame(height: CGFloat(min(max(model.fileNames.count, 1), 3)) * 50) + .frame(height: CGFloat(min(max(model.fileNames.count, 1), visibleRows)) * 50) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, 16) + if fillsHeight { Spacer(minLength: 0) } + Button(action: model.upload) { Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) .lineLimit(2) @@ -303,6 +331,11 @@ struct NCShareView: View { .allowsHitTesting(false) } +#Preview("Landscape", traits: .fixedLayout(width: 844, height: 390)) { + NCShareView(model: .preview()) + .allowsHitTesting(false) +} + #Preview("No folders") { let model = NCShareViewModel.preview() model.folderChildren = [:] diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 5091f6d40a..a47884ab6e 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -916,3 +916,5 @@ "_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."; From 07039b3b795057a465fc7714741ef29f8cb1b97d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 11:24:57 +0100 Subject: [PATCH 10/42] fix: improve Share extension landscape layout Place the folder header and upload button across both columns, let the file list fill available height, and round selected folder highlights. Signed-off-by: Marino Faggiana --- Share/View/NCShareView.swift | 179 ++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 77 deletions(-) diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 22093a6a3f..8cb077f5ab 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -16,17 +16,24 @@ struct NCShareView: View { statusView GeometryReader { geometry in if geometry.size.width >= 600 && geometry.size.height < 450 && geometry.size.width > geometry.size.height { - HStack(alignment: .top, spacing: 0) { - folderList - .frame(maxWidth: .infinity) - commands( - visibleRows: min(3, max(1, Int((geometry.size.height - 100) / 50))), - fillsHeight: true) - .frame(width: min(320, geometry.size.width * 0.38)) + VStack(spacing: 0) { + 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) + uploadButton + .padding(.top, 10) + .padding(.bottom, 16) } + .background(Color(uiColor: .systemGroupedBackground)) } else { VStack(spacing: 0) { - folderList + folderList() commands() } } @@ -102,10 +109,12 @@ struct NCShareView: View { } } - private var folderList: some View { + private func folderList(showHeader: Bool = true, horizontalInset: CGFloat = 16) -> some View { VStack(spacing: 0) { - createFolderHeader - .padding(.horizontal, 16) + if showHeader { + createFolderHeader + .padding(.horizontal, horizontalInset) + } List { Section { if !model.recentFolders.isEmpty { @@ -153,7 +162,7 @@ struct NCShareView: View { .environment(\.defaultMinListRowHeight, 50) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) + .padding(.horizontal, horizontalInset) } .frame(maxHeight: .infinity) .background(Color(uiColor: .systemGroupedBackground)) @@ -235,6 +244,7 @@ struct NCShareView: View { Spacer(minLength: 4) if isSelected { Image(systemName: "checkmark") + .frame(width: 50, height: 50) .accessibilityHidden(true) } } @@ -244,85 +254,100 @@ struct NCShareView: View { .buttonStyle(.borderless) .accessibilityAddTraits(isSelected ? .isSelected : []) } + .background { + if isSelected { + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.1)) + } + } .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) - .listRowBackground(isSelected ? Color.accentColor.opacity(0.1) : Color(uiColor: .secondarySystemGroupedBackground)) + .listRowInsets(EdgeInsets(top: 0, leading: 2, bottom: 0, trailing: 2)) + .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) } - private func commands(visibleRows: Int = 3, fillsHeight: Bool = false) -> some View { - VStack(spacing: 12) { - List(model.fileNames, id: \.self) { fileName in - HStack { - Button { - model.rename(fileName) - } label: { - Label { - Text(verbatim: fileName).lineLimit(2) - } icon: { - Group { - if let thumbnail = model.fileThumbnails[fileName] { - Image(uiImage: thumbnail) - .resizable() - .scaledToFit() - } 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) - } - .buttonStyle(.plain) - Spacer() - Menu { - Button(NSLocalizedString("_rename_file_", comment: "")) { - model.rename(fileName) - } - Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { - model.remove(fileName) + private func fileList(fillsHeight: Bool = false, horizontalInset: CGFloat = 16) -> some View { + List(model.fileNames, id: \.self) { fileName in + HStack { + Button { + model.rename(fileName) + } label: { + Label { + Text(verbatim: fileName).lineLimit(2) + } icon: { + Group { + if let thumbnail = model.fileThumbnails[fileName] { + Image(uiImage: thumbnail) + .resizable() + .scaledToFit() + } else { + Image(systemName: model.fileIcon(named: fileName)) } - } label: { - Image(systemName: "ellipsis.circle") - .frame(minWidth: 50, minHeight: 50) } - .accessibilityLabel(Text(verbatim: fileName)) + .frame(width: 36, height: 36) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .accessibilityHidden(true) + .task(id: fileName) { model.loadThumbnail(named: fileName) } } - .font(.body) - .frame(minHeight: 50) - .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)) - .listRowSeparator(.hidden) + .foregroundStyle(.primary) } - .listStyle(.plain) - .contentMargins(.vertical, 0, for: .scrollContent) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 50) - .frame(height: CGFloat(min(max(model.fileNames.count, 1), visibleRows)) * 50) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) + .buttonStyle(.plain) + Spacer() + Menu { + Button(NSLocalizedString("_rename_file_", comment: "")) { + model.rename(fileName) + } + Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { + model.remove(fileName) + } + } label: { + Image(systemName: "ellipsis.circle") + .frame(minWidth: 50, minHeight: 50) + } + .accessibilityLabel(Text(verbatim: fileName)) + } + .font(.body) + .frame(minHeight: 50) + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) + .listRowSeparator(.hidden) + } + .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) - if fillsHeight { Spacer(minLength: 0) } + .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + } - Button(action: model.upload) { - Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) - .lineLimit(2) - .truncationMode(.middle) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 12)) - .padding(.horizontal, 16) - .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading) + private var uploadButton: some View { + Button(action: model.upload) { + Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) + .lineLimit(2) + .truncationMode(.middle) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .padding(.horizontal, 16) + .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading) + .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + } + + private func commands() -> some View { + VStack(spacing: 10) { + fileList() + uploadButton } .padding(.top, 10) .padding(.bottom, 16) .background(Color(uiColor: .systemGroupedBackground)) - .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } + } #if DEBUG && SHARE_PREVIEWS From c136172b081a4a0180f44e65148e04591030efb5 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 11:29:47 +0100 Subject: [PATCH 11/42] fix: open share file actions from the entire row Signed-off-by: Marino Faggiana --- Share/View/NCShareView.swift | 88 ++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 8cb077f5ab..14f167c55b 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -266,32 +266,7 @@ struct NCShareView: View { } private func fileList(fillsHeight: Bool = false, horizontalInset: CGFloat = 16) -> some View { - List(model.fileNames, id: \.self) { fileName in - HStack { - Button { - model.rename(fileName) - } label: { - Label { - Text(verbatim: fileName).lineLimit(2) - } icon: { - Group { - if let thumbnail = model.fileThumbnails[fileName] { - Image(uiImage: thumbnail) - .resizable() - .scaledToFit() - } 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) - } - .buttonStyle(.plain) - Spacer() + List(model.fileNames, id: \.self) { fileName in Menu { Button(NSLocalizedString("_rename_file_", comment: "")) { model.rename(fileName) @@ -300,26 +275,48 @@ struct NCShareView: View { model.remove(fileName) } } label: { - Image(systemName: "ellipsis.circle") - .frame(minWidth: 50, minHeight: 50) + HStack { + Label { + Text(verbatim: fileName).lineLimit(2) + } icon: { + Group { + if let thumbnail = model.fileThumbnails[fileName] { + Image(uiImage: thumbnail) + .resizable() + .scaledToFit() + } 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.circle") + .foregroundStyle(Color.accentColor) + .frame(width: 50, height: 50) + .accessibilityHidden(true) + } + .font(.body) + .frame(maxWidth: .infinity, minHeight: 50) + .contentShape(Rectangle()) } - .accessibilityLabel(Text(verbatim: fileName)) + .buttonStyle(.plain) + .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) + .listRowSeparator(.hidden) } - .font(.body) - .frame(minHeight: 50) - .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) - .listRowSeparator(.hidden) - } - .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) + .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) - + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, horizontalInset) .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } @@ -356,11 +353,6 @@ struct NCShareView: View { .allowsHitTesting(false) } -#Preview("Landscape", traits: .fixedLayout(width: 844, height: 390)) { - NCShareView(model: .preview()) - .allowsHitTesting(false) -} - #Preview("No folders") { let model = NCShareViewModel.preview() model.folderChildren = [:] From 2dab3b126fa63aa2d5b497f12a4c7f7bfd2328b2 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 11:47:08 +0100 Subject: [PATCH 12/42] fix: present share file actions in a confirmation dialog Add an explicit cancel action and refine folder label colors and selection padding. Signed-off-by: Marino Faggiana --- Share/View/NCShareView.swift | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 14f167c55b..d551dd971f 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -7,6 +7,7 @@ import SwiftUI @MainActor struct NCShareView: View { @ObservedObject var model: NCShareViewModel + @State private var fileActions: String? var body: some View { NavigationStack { @@ -231,7 +232,7 @@ struct NCShareView: View { VStack(alignment: .leading, spacing: 1) { Text(verbatim: folder.name) .font(.body) - .foregroundStyle(.primary) + .foregroundStyle(Color(uiColor: .label)) .lineLimit(1) if depth == nil { Text(verbatim: folder.path) @@ -258,6 +259,7 @@ struct NCShareView: View { if isSelected { RoundedRectangle(cornerRadius: 8) .fill(Color.accentColor.opacity(0.1)) + .padding(.horizontal, 5) } } .listRowSeparator(.hidden) @@ -267,13 +269,8 @@ struct NCShareView: View { private func fileList(fillsHeight: Bool = false, horizontalInset: CGFloat = 16) -> some View { List(model.fileNames, id: \.self) { fileName in - Menu { - Button(NSLocalizedString("_rename_file_", comment: "")) { - model.rename(fileName) - } - Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { - model.remove(fileName) - } + Button { + fileActions = fileName } label: { HStack { Label { @@ -305,6 +302,18 @@ struct NCShareView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .confirmationDialog(Text(verbatim: fileName), isPresented: Binding( + get: { fileActions == fileName }, + set: { if !$0 { fileActions = nil } } + ), titleVisibility: .visible) { + Button(NSLocalizedString("_rename_file_", comment: "")) { + model.rename(fileName) + } + Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { + model.remove(fileName) + } + Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) { } + } .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) .listRowSeparator(.hidden) } From f5e30acb013ea2d20d9e79a6f79fb01dfa094d5c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 29 Oct 2026 12:02:08 +0100 Subject: [PATCH 13/42] feat: prioritize and badge favorite folders in Share extension Show favorite folders first with accessible star badges, and scale file thumbnails to fill their frames. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Folders.swift | 6 ++++-- Share/Model/NCShareViewModel.swift | 3 ++- Share/View/NCShareView.swift | 21 ++++++++++++++++++- .../en.lproj/Localizable.strings | 2 ++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 20ee392543..e6911d07dd 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -201,7 +201,8 @@ extension NCShareViewModel { ? 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 metadatas.map { metadata in + 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) } @@ -229,7 +230,8 @@ extension NCShareViewModel { } 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) + serverUrl: url, path: folderPath(url, home: home), image: image, + isEncrypted: metadata.e2eEncrypted, isFavorite: metadata.favorite) } private func folderPath(_ url: String, home: String) -> String { diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index b406fb0704..4ac535a021 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -502,6 +502,7 @@ struct NCShareFolderItem: Identifiable { let path: String let image: UIImage? let isEncrypted: Bool + var isFavorite = false } struct NCShareFolderRow: Identifiable { @@ -557,7 +558,7 @@ extension NCShareViewModel { model.accountName = "Personal" 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) + 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.serverUrl = documents.serverUrl diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index d551dd971f..ff6a70b1c8 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -228,6 +228,25 @@ struct NCShareView: View { .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) @@ -280,7 +299,7 @@ struct NCShareView: View { if let thumbnail = model.fileThumbnails[fileName] { Image(uiImage: thumbnail) .resizable() - .scaledToFit() + .scaledToFill() } else { Image(systemName: model.fileIcon(named: fileName)) } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index a47884ab6e..1b7f005422 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -918,3 +918,5 @@ "_share_capabilities_retry_" = "Try again"; "_share_online_required_" = "An internet connection is required to upload files. Reconnect to continue."; + +"_share_favorite_folder_" = "Favorite folder"; From d201d5a5ea2d1acdd367c210b9e003f7732d9195 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 10:39:10 +0100 Subject: [PATCH 14/42] fix: improve Share extension folder browsing and upload feedback Process only directories without replacing cached file metadata. Refine landscape layout, cap Dynamic Type scaling, and show conflict checks in the upload button. Signed-off-by: Marino Faggiana --- .../xcshareddata/xcschemes/Nextcloud.xcscheme | 6 +- .../xcshareddata/xcschemes/Share.xcscheme | 2 +- Share/Model/NCShareViewModel+Folders.swift | 45 +++++++++--- Share/View/NCShareAccountMenu.swift | 1 + Share/View/NCShareUploadConflictView.swift | 2 +- Share/View/NCShareView.swift | 72 +++++++++++-------- .../en.lproj/Localizable.strings | 4 +- 7 files changed, 84 insertions(+), 48 deletions(-) diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme index 2d901b5ea6..81343d5379 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 = "/Users/marinofaggiana/Library/Developer/CoreSimulator/Devices/1B0CDC53-7020-42D2-8FC5-7055D11CA2E1/data/Containers/Bundle/Application/39406898-7B5C-4746-8D32-5B40D5250C45/Nextcloud.app"> [NCShareFolderItem] { diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift index 5af0ce6c9d..b9a623efa6 100644 --- a/Share/View/NCShareAccountMenu.swift +++ b/Share/View/NCShareAccountMenu.swift @@ -33,6 +33,7 @@ struct NCShareAccountMenu: View { } } .buttonStyle(.plain) + .dynamicTypeSize(.xSmall ... .accessibility2) .disabled(model.accounts.count < 2) } diff --git a/Share/View/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift index b1bcd063a1..3a222c0c90 100644 --- a/Share/View/NCShareUploadConflictView.swift +++ b/Share/View/NCShareUploadConflictView.swift @@ -21,7 +21,7 @@ struct NCShareUploadConflictView: View { ForEach(confirmation.renamedItems, id: \.sourceName) { item in VStack(alignment: .leading, spacing: 4) { Text(verbatim: item.sourceName) - .font(.caption) + .cappedFont(.caption, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) Text(verbatim: item.fileName) } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index ff6a70b1c8..7eb266ac24 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -15,8 +15,11 @@ struct NCShareView: View { if model.isAvailable && model.isAuthenticated { VStack(spacing: 0) { statusView + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) GeometryReader { geometry in - if geometry.size.width >= 600 && geometry.size.height < 450 && geometry.size.width > geometry.size.height { + if UIDevice.current.userInterfaceIdiom == .phone && geometry.size.width > geometry.size.height { VStack(spacing: 0) { createFolderHeader .padding(.horizontal, 16) @@ -40,6 +43,7 @@ struct NCShareView: View { } } } + .background(Color(uiColor: .systemGroupedBackground)) } else if model.isSwitchingAccount && model.isAuthenticated { ProgressView() .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) @@ -47,6 +51,7 @@ struct NCShareView: View { Color(uiColor: .systemBackground) } } + .dynamicTypeSize(.xSmall ... .accessibility2) .background(Color(uiColor: .systemBackground)) .alert(NSLocalizedString("_error_", comment: ""), isPresented: $model.isShowingInputError) { Button(NSLocalizedString("_ok_", comment: "")) { model.acknowledgeInputError() } @@ -58,6 +63,8 @@ struct NCShareView: View { } .navigationTitle(model.navigationTitle) .navigationBarTitleDisplayMode(.inline) + .toolbar(.visible, for: .navigationBar) + .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { if model.isAvailable && model.isAuthenticated { @@ -66,7 +73,7 @@ struct NCShareView: View { .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) if model.isLoading || !model.loadingFolderURLs.isEmpty { ProgressView() - .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) + .accessibilityLabel(NSLocalizedString(model.isLoading ? "_share_checking_folder_" : "_share_loading_folders_", comment: "")) .padding(.trailing, 8) } } @@ -84,7 +91,7 @@ struct NCShareView: View { if model.isOffline { VStack(spacing: 8) { Label(NSLocalizedString("_share_online_required_", comment: ""), systemImage: "wifi.slash") - .font(.subheadline) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) .multilineTextAlignment(.center) Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryConnection) } @@ -92,7 +99,7 @@ struct NCShareView: View { } else if !model.capabilitiesErrorMessage.isEmpty { VStack(spacing: 8) { Text(verbatim: model.capabilitiesErrorMessage) - .font(.subheadline) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) .multilineTextAlignment(.center) Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryCapabilities) .disabled(model.isSwitchingAccount) @@ -104,9 +111,6 @@ struct NCShareView: View { } else if model.isPreparingInput { ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) .padding() - } else if model.isPreparingUpload { - ProgressView(NSLocalizedString("_share_conflict_checking_", comment: "")) - .padding() } } @@ -141,7 +145,7 @@ struct NCShareView: View { if let error = model.folderLoadErrors[row.folder.serverUrl] { VStack(alignment: .leading, spacing: 6) { Text(verbatim: error) - .font(.caption) + .cappedFont(.caption, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } } @@ -149,7 +153,7 @@ struct NCShareView: View { } else if model.loadedFolderURLs.contains(row.folder.serverUrl), model.folderChildren[row.folder.serverUrl]?.isEmpty == true { Text(NSLocalizedString("_files_no_folders_", comment: "")) - .font(.caption) + .cappedFont(.caption, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) } @@ -166,6 +170,7 @@ struct NCShareView: View { .padding(.horizontal, horizontalInset) } .frame(maxHeight: .infinity) + .clipped() .background(Color(uiColor: .systemGroupedBackground)) .disabled(model.isLoading || model.isPreparingUpload || !model.isAccountReady || model.isLoadingAccounts) } @@ -178,11 +183,12 @@ struct NCShareView: View { Text(NSLocalizedString("_create_folder_", comment: "")) Image(systemName: "folder.badge.plus") } - .font(.subheadline) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) .frame(minHeight: 50) .contentShape(Rectangle()) } .buttonStyle(.borderless) + .tint(.blue) .disabled(model.isPreparingInput || !model.inputErrorMessage.isEmpty || (model.selectedFolder.map { model.loadingFolderURLs.contains($0.serverUrl) } ?? true)) } @@ -197,15 +203,9 @@ struct NCShareView: View { Button { model.toggleFolder(folder) } label: { - Group { - if model.loadingFolderURLs.contains(folder.serverUrl) { - ProgressView() - } else { - Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") - } - } - .frame(width: 50, height: 50) - .contentShape(Rectangle()) + Image(systemName: model.expandedFolderURLs.contains(folder.serverUrl) ? "chevron.down" : "chevron.right") + .frame(width: 50, height: 50) + .contentShape(Rectangle()) } .buttonStyle(.borderless) .accessibilityLabel(String(format: NSLocalizedString( @@ -213,7 +213,7 @@ struct NCShareView: View { .padding(.leading, CGFloat(min(depth, 8)) * 16) } else { Image(systemName: "clock") - .font(.subheadline) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) .frame(width: 50, height: 50) .accessibilityHidden(true) @@ -250,12 +250,12 @@ struct NCShareView: View { } VStack(alignment: .leading, spacing: 1) { Text(verbatim: folder.name) - .font(.body) + .cappedFont(.body, maxDynamicType: .accessibility2) .foregroundStyle(Color(uiColor: .label)) .lineLimit(1) if depth == nil { Text(verbatim: folder.path) - .font(.caption2) + .cappedFont(.caption2, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) @@ -316,7 +316,7 @@ struct NCShareView: View { .frame(width: 50, height: 50) .accessibilityHidden(true) } - .font(.body) + .cappedFont(.body, maxDynamicType: .accessibility2) .frame(maxWidth: .infinity, minHeight: 50) .contentShape(Rectangle()) } @@ -335,6 +335,7 @@ struct NCShareView: View { } .listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 2)) .listRowSeparator(.hidden) + .listRowBackground(Color(uiColor: .secondarySystemGroupedBackground)) } .listStyle(.plain) .contentMargins(.vertical, 0, for: .scrollContent) @@ -349,18 +350,27 @@ struct NCShareView: View { } private var uploadButton: some View { - Button(action: model.upload) { - Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) + Button(action: model.upload) { + HStack(spacing: 8) { + if model.isPreparingUpload { + ProgressView() + .tint(.white) + Text(NSLocalizedString("_share_conflict_checking_", comment: "")) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) + } + } .lineLimit(2) .truncationMode(.middle) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 12)) - .padding(.horizontal, 16) - .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading) - .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + } + .buttonStyle(.borderedProminent) + .buttonBorderShape(.roundedRectangle(radius: 12)) + .padding(.horizontal, 16) + .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading || + model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || + model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } private func commands() -> some View { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 1b7f005422..25057aad80 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -916,7 +916,7 @@ "_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…"; From 8f1ae5b24d448f8d9c160aa9eab9498c3708ebdd Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 11:26:56 +0100 Subject: [PATCH 15/42] chore: remove Share folder timing logs and update launch path Signed-off-by: Marino Faggiana --- .../xcshareddata/xcschemes/Share.xcscheme | 2 +- Share/Model/NCShareViewModel+Folders.swift | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Share.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Share.xcscheme index eff647a97f..6de120ed2f 100755 --- a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Share.xcscheme +++ b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Share.xcscheme @@ -123,7 +123,7 @@ + RemotePath = "/var/containers/Bundle/Application/690609F7-C7AD-4102-9416-5C89CC5CD598/Nextcloud.app"> [NCShareFolderItem] { From 63aaf3d4513df2dbca589d63f7bf49d96553693d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 11:37:36 +0100 Subject: [PATCH 16/42] chore: track NextcloudKit main branch Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 180 +++++++++--------- .../xcshareddata/swiftpm/Package.resolved | 4 +- 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index bf5a1a5340..8f3b0e0db8 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -7,33 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - AB1028260000000000090121 /* NCShareRecentFoldersStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */; }; - AB1028260000000000090120 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; - AB1028260000000000090110 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; - AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; - AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; - AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; - AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.swift */; }; - AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; - AB1028260000000000080004 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; - AB1028260000000000080003 /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; - AB1028260000000000080002 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; - AB1028260000000000080001 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; - AB1028260000000000080000 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; - AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000060000 /* NCShareAccountMenu.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 */; }; - AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000050001 /* NCShareUploadConflictView.swift */; }; - AB1028260000000000040001 /* NCShareLoaderItemProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */; }; - AB1028260000000000040002 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; - AB102826000000000003000A /* NCShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB102826000000000002000A /* NCShareViewModel.swift */; }; - AB1028260000000000030009 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; - AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020008 /* NCShareSessionStore.swift */; }; - AB1028260000000000030006 /* NCShareLoaderItemProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */; }; - AB1028260000000000030004 /* NCShareViewModel+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020004 /* NCShareViewModel+Upload.swift */; }; - AB1028260000000000030003 /* NCShareViewModel+Folders.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020003 /* NCShareViewModel+Folders.swift */; }; - AB1028260000000000030002 /* NCShareViewModel+Accounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */; }; - AB1028260000000000030000 /* NCShareEntryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020000 /* NCShareEntryController.swift */; }; 22BA14432CF44BDBA2E6C291 /* NCDocumentEditorSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B74B947E07444CC9CFE0A6C /* NCDocumentEditorSupportTests.swift */; }; 2C1D5D7623E2DE3300334ABB /* NCManageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BAADB51ED5A87C00B7EAD4 /* NCManageDatabase.swift */; }; 2C1D5D7923E2DE9100334ABB /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; @@ -97,6 +70,33 @@ 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 */; }; 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 */; }; @@ -1313,20 +1313,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - AB1028260000000000090001 /* NCShareRecentFoldersStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStoreTests.swift; sourceTree = ""; }; - AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareRecentFoldersStore.swift; sourceTree = ""; }; - AB1028260000000000060000 /* NCShareAccountMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareAccountMenu.swift; sourceTree = ""; }; - AB1028260000000000050300 /* NCShareUploadNamePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadNamePlannerTests.swift; sourceTree = ""; }; - AB1028260000000000050001 /* NCShareUploadConflictView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadConflictView.swift; sourceTree = ""; }; - AB1028260000000000040000 /* NCShareLoaderItemProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareLoaderItemProviderTests.swift; sourceTree = ""; }; - AB102826000000000002000A /* NCShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel.swift"; sourceTree = ""; }; - AB1028260000000000020009 /* NCShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareView.swift"; sourceTree = ""; }; - AB1028260000000000020008 /* NCShareSessionStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareSessionStore.swift"; sourceTree = ""; }; - AB1028260000000000020006 /* NCShareLoaderItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareLoaderItemProvider.swift"; sourceTree = ""; }; - AB1028260000000000020004 /* NCShareViewModel+Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Upload.swift"; sourceTree = ""; }; - AB1028260000000000020003 /* NCShareViewModel+Folders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Folders.swift"; sourceTree = ""; }; - AB1028260000000000020002 /* NCShareViewModel+Accounts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareViewModel+Accounts.swift"; sourceTree = ""; }; - AB1028260000000000020000 /* NCShareEntryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShareEntryController.swift"; sourceTree = ""; }; 131F8E2F43AD414E9DB53019 /* NCPasscodeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCPasscodeModel.swift; sourceTree = ""; }; 2C33C47F23E2C475005F963B /* Notification Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Notification Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 2C33C48123E2C475005F963B /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -1434,6 +1420,20 @@ 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 = ""; }; 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 = ""; }; @@ -2299,41 +2299,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - AB1028260000000000070000 /* View */ = { - isa = PBXGroup; - children = ( - AB1028260000000000020009 /* NCShareView.swift */, - AB1028260000000000060000 /* NCShareAccountMenu.swift */, - AB1028260000000000050001 /* NCShareUploadConflictView.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 = ""; - }; - AB102826000000000000FF00 /* UIKit */ = { - 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 */, - ); - path = UIKit; - sourceTree = ""; - }; 2C33C48023E2C475005F963B /* Notification Service Extension */ = { isa = PBXGroup; children = ( @@ -2425,6 +2390,41 @@ path = Extensions; sourceTree = ""; }; + AB102826000000000000FF00 /* UIKit */ = { + 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 */, + ); + path = UIKit; + sourceTree = ""; + }; + AB1028260000000000070000 /* View */ = { + isa = PBXGroup; + children = ( + AB1028260000000000020009 /* NCShareView.swift */, + AB1028260000000000060000 /* NCShareAccountMenu.swift */, + AB1028260000000000050001 /* NCShareUploadConflictView.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 = ( @@ -6487,7 +6487,6 @@ F77B0F9B1D118A16002130FE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SHARE_PREVIEWS"; ALWAYS_SEARCH_USER_PATHS = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; @@ -6502,6 +6501,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; }; @@ -6510,24 +6510,24 @@ F77B0F9C1D118A16002130FE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + ALWAYS_SEARCH_USER_PATHS = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + 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", - "NCShareView.swift", + NCShareViewModel.swift, + NCShareLoaderItemProvider.swift, + NCShareSessionStore.swift, + NCShareAccountMenu.swift, + NCShareUploadConflictView.swift, + NCShareView.swift, ); - ALWAYS_SEARCH_USER_PATHS = YES; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; - CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/Brand/iOSClient.entitlements"; - DEAD_CODE_STRIPPING = YES; GCC_SYMBOLS_PRIVATE_EXTERN = YES; INFOPLIST_FILE = "$(SRCROOT)/Brand/iOSClient.plist"; OTHER_LDFLAGS = ""; @@ -7195,8 +7195,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - kind = exactVersion; - version = 7.6.2; + branch = main; + kind = branch; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d7904e1afe..405b41a798 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" : "705e69cf0526cac0bdeaf21699c41c895cae42a0", - "version" : "7.6.2" + "branch" : "main", + "revision" : "eb87c939dd740fe9edf33dc4ee0ca336cf222fb5" } }, { From 665123ddbbc8903fc1b06a43bda78459b4b7353c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 16:36:10 +0100 Subject: [PATCH 17/42] fix: improve share extension errors and destination readiness Show errors inline with retry or acknowledgment actions, and block uploads while the destination is loading or has failed. Label recent and all folders, and use English (US) for previews and debug launches. Signed-off-by: Marino Faggiana --- .../xcshareddata/xcschemes/Nextcloud.xcscheme | 2 + Share/Model/NCShareViewModel.swift | 25 ++++-- Share/View/NCShareView.swift | 79 ++++++++++++------- .../en.lproj/Localizable.strings | 2 +- 4 files changed, 70 insertions(+), 38 deletions(-) diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme index 81343d5379..93d074a259 100755 --- a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme +++ b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme @@ -118,6 +118,8 @@ Void)? @Published var navigationTitle = NCBrandOptions.shared.brand @Published var accountName = "" @Published var accounts: [NCShareAccountItem] = [] @@ -55,6 +57,12 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var capabilitiesErrorMessage = "" var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount && !isOffline } + var isUploadDestinationReady: Bool { + guard let selectedFolder else { return false } + return isAccountReady && !loadingFolderURLs.contains(selectedFolder.serverUrl) && + folderLoadErrors[selectedFolder.serverUrl] == nil + } + var capabilitiesTask: Task? var capabilitiesRequest: URLSessionTask? var accountsTask: Task? @@ -370,12 +378,15 @@ final class NCShareViewModel: NSObject, ObservableObject { } 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?() - })) - presenter?.present(alertController, animated: true) + alertMessage = NSLocalizedString(description.replacingOccurrences(of: "\t", with: "\n"), comment: "") + alertDismissAction = onDismiss + } + + func acknowledgeAlert() { + let action = alertDismissAction + alertMessage = "" + alertDismissAction = nil + action?() } func setNavigationBar(navigationTitle: String) { @@ -452,7 +463,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } func upload() { - guard isAccountReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, + guard isUploadDestinationReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } guard let capabilities = serverCapabilities else { return } isPreparingUpload = true diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 7eb266ac24..532f621279 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -53,11 +53,6 @@ struct NCShareView: View { } .dynamicTypeSize(.xSmall ... .accessibility2) .background(Color(uiColor: .systemBackground)) - .alert(NSLocalizedString("_error_", comment: ""), isPresented: $model.isShowingInputError) { - Button(NSLocalizedString("_ok_", comment: "")) { model.acknowledgeInputError() } - } message: { - Text(verbatim: model.inputErrorMessage) - } .sheet(item: $model.uploadConfirmation, onDismiss: { model.uploadConfirmationDismissed() }) { confirmation in NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) } @@ -88,7 +83,11 @@ struct NCShareView: View { @ViewBuilder private var statusView: some View { - if model.isOffline { + if !model.alertMessage.isEmpty { + statusError(model.alertMessage) { model.acknowledgeAlert() } + } else if !model.inputErrorMessage.isEmpty && model.isShowingInputError { + 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) @@ -105,6 +104,15 @@ struct NCShareView: View { .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.isLoadingCapabilities { ProgressView(NSLocalizedString("_share_capabilities_loading_", comment: "")) .padding() @@ -114,6 +122,16 @@ struct NCShareView: View { } } + 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 { @@ -123,35 +141,18 @@ struct NCShareView: View { 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) - .padding(.top, !model.recentFolders.isEmpty && row.id == model.visibleFolderRows.first?.id ? 12 : 0) - .overlay(alignment: .top) { - if !model.recentFolders.isEmpty, row.id == model.visibleFolderRows.first?.id { - Rectangle() - .fill(Color(uiColor: .separator)) - .frame(height: 1) - .padding(.top, 6) - .padding(.horizontal, -12) - .allowsHitTesting(false) - .accessibilityHidden(true) - } - } if model.expandedFolderURLs.contains(row.folder.serverUrl) { - if let error = model.folderLoadErrors[row.folder.serverUrl] { - VStack(alignment: .leading, spacing: 6) { - Text(verbatim: error) - .cappedFont(.caption, maxDynamicType: .accessibility2) - .foregroundStyle(.secondary) - Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } - } - .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) - } else if model.loadedFolderURLs.contains(row.folder.serverUrl), - model.folderChildren[row.folder.serverUrl]?.isEmpty == true { + if model.folderLoadErrors[row.folder.serverUrl] == nil, + model.loadedFolderURLs.contains(row.folder.serverUrl), + model.folderChildren[row.folder.serverUrl]?.isEmpty == true { Text(NSLocalizedString("_files_no_folders_", comment: "")) .cappedFont(.caption, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) @@ -164,7 +165,7 @@ struct NCShareView: View { .listStyle(.plain) .contentMargins(.vertical, 0, for: .scrollContent) .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 50) + .environment(\.defaultMinListRowHeight, 0) .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, horizontalInset) @@ -175,6 +176,19 @@ struct NCShareView: View { .disabled(model.isLoading || model.isPreparingUpload || !model.isAccountReady || model.isLoadingAccounts) } + 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() @@ -368,7 +382,7 @@ struct NCShareView: View { .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 12)) .padding(.horizontal, 16) - .disabled(model.fileNames.isEmpty || model.selectedFolder == nil || model.isLoading || + .disabled(model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } @@ -388,6 +402,7 @@ struct NCShareView: View { #if DEBUG && SHARE_PREVIEWS #Preview("Share") { NCShareView(model: .preview()) + .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) } @@ -397,6 +412,7 @@ struct NCShareView: View { model.recentFolders = [] model.loadedFolderURLs = Set(model.folders.map(\.serverUrl)) return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) } @@ -405,6 +421,7 @@ struct NCShareView: View { model.fileNames = [] model.isPreparingInput = true return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) } #Preview("Capabilities unavailable") { @@ -412,6 +429,7 @@ struct NCShareView: View { model.serverCapabilities = nil model.capabilitiesErrorMessage = NSLocalizedString("_share_capabilities_unavailable_", comment: "") return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) } @@ -420,6 +438,7 @@ struct NCShareView: View { model.serverCapabilities = nil model.isLoadingCapabilities = true return NCShareView(model: model) + .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) } #endif diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index b19073c46e..845630879e 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -904,7 +904,7 @@ /* Share extension destination browser */ "_share_home_folder_" = "My files"; -"_share_recent_folders_" = "Recent folders"; +"_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 %@"; From 743fb90c9ec4dc7b4b7c85e1a51025029957da3f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 16:40:15 +0100 Subject: [PATCH 18/42] refactor: remove legacy UIKit share extension Delete obsolete UIKit sources and storyboard, remove their Xcode project references, and drop the unused animated GIF bridging import. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 22 - Share/Share-Bridging-Header.h | 1 - Share/UIKit/MainInterface.storyboard | 267 -------- Share/UIKit/NCShareCell.swift | 75 --- Share/UIKit/NCShareExtension+DataSource.swift | 192 ------ Share/UIKit/NCShareExtension+Files.swift | 203 ------ ...reExtension+NCAccountRequestDelegate.swift | 132 ---- Share/UIKit/NCShareExtension.swift | 593 ------------------ Share/UIKit/NCShareExtensionData.swift | 47 -- 9 files changed, 1532 deletions(-) delete mode 100644 Share/UIKit/MainInterface.storyboard delete mode 100644 Share/UIKit/NCShareCell.swift delete mode 100644 Share/UIKit/NCShareExtension+DataSource.swift delete mode 100644 Share/UIKit/NCShareExtension+Files.swift delete mode 100644 Share/UIKit/NCShareExtension+NCAccountRequestDelegate.swift delete mode 100644 Share/UIKit/NCShareExtension.swift delete mode 100644 Share/UIKit/NCShareExtensionData.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 8f3b0e0db8..3226e01366 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -1443,9 +1443,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 = ""; }; @@ -1454,7 +1451,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 = ""; }; @@ -1567,8 +1563,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 = ""; }; @@ -1920,7 +1914,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 = ""; }; @@ -2390,20 +2383,6 @@ path = Extensions; sourceTree = ""; }; - AB102826000000000000FF00 /* UIKit */ = { - 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 */, - ); - path = UIKit; - sourceTree = ""; - }; AB1028260000000000070000 /* View */ = { isa = PBXGroup; children = ( @@ -3664,7 +3643,6 @@ AB1028260000000000020008 /* NCShareSessionStore.swift */, AB1028260000000000070000 /* View */, AB1028260000000000070001 /* Model */, - AB102826000000000000FF00 /* UIKit */, F7148046262EBE4B00693E51 /* Share-Bridging-Header.h */, ); path = Share; 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/UIKit/MainInterface.storyboard b/Share/UIKit/MainInterface.storyboard deleted file mode 100644 index 221ff91c0c..0000000000 --- a/Share/UIKit/MainInterface.storyboard +++ /dev/null @@ -1,267 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Share/UIKit/NCShareCell.swift b/Share/UIKit/NCShareCell.swift deleted file mode 100644 index 898f980850..0000000000 --- a/Share/UIKit/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/UIKit/NCShareExtension+DataSource.swift b/Share/UIKit/NCShareExtension+DataSource.swift deleted file mode 100644 index 03836ab45f..0000000000 --- a/Share/UIKit/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/UIKit/NCShareExtension+Files.swift b/Share/UIKit/NCShareExtension+Files.swift deleted file mode 100644 index 2d49ffcbc6..0000000000 --- a/Share/UIKit/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 { - self.showAlert(description: resultsReadFolder.error.errorDescription) - } - } -} - -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/UIKit/NCShareExtension+NCAccountRequestDelegate.swift b/Share/UIKit/NCShareExtension+NCAccountRequestDelegate.swift deleted file mode 100644 index b1de031237..0000000000 --- a/Share/UIKit/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/UIKit/NCShareExtension.swift b/Share/UIKit/NCShareExtension.swift deleted file mode 100644 index 395334677e..0000000000 --- a/Share/UIKit/NCShareExtension.swift +++ /dev/null @@ -1,593 +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 in - if error == .success { - Task { - await self.loadFolder() - } - } else { - self.showAlert(title: "_error_createsubfolders_upload_", description: error.errorDescription) - } - } - 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? - 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 { - break - } - } - - if error == .success { - banner?.update(payload: LucidBannerPayload.Update(stage: .success, horizontalLayout: .centered(width: 100)), for: self.token) - } else { - banner?.update(payload: LucidBannerPayload.Update(subtitle: error?.errorDescription, stage: .error), 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/UIKit/NCShareExtensionData.swift b/Share/UIKit/NCShareExtensionData.swift deleted file mode 100644 index 1861a4cfea..0000000000 --- a/Share/UIKit/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 - } -} From fdeebad27b50efad6d65b585d74e43bf09842ae0 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 16:45:21 +0100 Subject: [PATCH 19/42] chore: trim Share extension dependencies and update contributor docs Remove unused sources, resources, and package dependencies from the Share target. Refresh README guidance for SPDX headers, Xcode setup, and test servers. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 56 ----------------------------- README.md | 15 ++++---- 2 files changed, 9 insertions(+), 62 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 3226e01366..04a9b109c4 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -167,7 +167,6 @@ 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 */; }; @@ -260,7 +259,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 */; }; @@ -290,7 +288,6 @@ 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 */; }; @@ -299,8 +296,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 */; }; - 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 */; }; @@ -360,7 +355,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 */; }; @@ -462,7 +456,6 @@ 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 */; }; @@ -509,7 +502,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 */; }; @@ -684,7 +676,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 */; }; @@ -844,17 +835,13 @@ 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 */; }; - 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 */; }; @@ -894,7 +881,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 */; }; @@ -983,9 +969,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 */; }; @@ -996,7 +980,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 */; }; @@ -1064,8 +1047,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 */; }; @@ -2192,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; }; @@ -4090,14 +4068,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"; @@ -4499,17 +4474,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F7148054262ED51000693E51 /* NCListCell.xib in Resources */, F7E4022C2BA85D1D007E5609 /* PrivacyInfo.xcprivacy in Resources */, - F7CBC1242BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */, - F7B398432A6A91D5007538D6 /* NCSectionFirstHeader.xib 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 */, - F79ED0F22D2FCA6A00A389D9 /* NCRecommendationsCell.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4858,10 +4828,8 @@ 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 */, @@ -4874,7 +4842,6 @@ 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 */, @@ -4897,11 +4864,9 @@ 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 */, - F74D50362C9856D300BBBF4C /* NCCollectionViewDataSource.swift in Sources */, F7BDC1D4300F440A00C5D9FA /* NCManageDatabase+MediaPreviewBackfill.swift in Sources */, F7BD71E62636EAFC00643C34 /* NCNetworkingE2EE.swift in Sources */, A1E2EE020000000000000003 /* NCEndToEndKeySetAccess.swift in Sources */, @@ -4911,15 +4876,10 @@ 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 */, - 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 */, F7CB77652F58463E00DE649A /* UIFont+Extension.swift in Sources */, @@ -4948,7 +4908,6 @@ 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 */, F7DA0F662F66AA0D0033020C /* ShowBanner.swift in Sources */, F73EF7C22B02250B0087E6E9 /* NCManageDatabase+GPS.swift in Sources */, @@ -7278,11 +7237,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" */; @@ -7388,11 +7342,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" */; @@ -7473,11 +7422,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/README.md b/README.md index d6a8fc34b0..cc80517414 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,12 @@ If you want to [contribute](https://nextcloud.com/contribute/) to Nextcloud, you [GPLv3](LICENSE.txt) with [Apple app store exception](COPYING.iOS). -Nextcloud doesn't require a CLA (Contributor License Agreement). The copyright belongs to all the individual contributors. Therefore we recommend that every contributor adds following line to the header of a file, if they changed it substantially: +Nextcloud doesn't require a CLA (Contributor License Agreement). The copyright belongs to all the individual contributors. Use SPDX headers for source files, preserving existing contributor credits and adding your own when making substantial changes: ``` -@copyright Copyright (c) , () +// 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). From a9d2f4fc84055d21e56818fe53e6848af0c9c312 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 30 Oct 2026 16:53:49 +0100 Subject: [PATCH 20/42] scheme Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme index 93d074a259..3b6b034618 100755 --- a/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme +++ b/Nextcloud.xcodeproj/xcshareddata/xcschemes/Nextcloud.xcscheme @@ -118,11 +118,11 @@ Date: Sun, 1 Nov 2026 11:28:45 +0100 Subject: [PATCH 21/42] fix: show cached share destinations before loading capabilities Initialize the folder browser before fetching server capabilities, then refresh folders once capabilities load. Show progress or errors while the share view initializes. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 14 ++++++++++++-- Share/Model/NCShareViewModel+Folders.swift | 4 ++-- Share/View/NCShareView.swift | 20 ++++++++++++++++---- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index a209a9600c..a9f59f365d 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -83,11 +83,17 @@ extension NCShareViewModel { account: account, urlBase: session.urlBase, userId: session.userId) serverUrl = utilityFileSystem.getHomeServer(session: session) setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) + // Publish the local browser before capabilities can trigger a network request. + await initializeFolderBrowser(refreshFromServer: false) + guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } showAccountView() + isSwitchingAccount = false loadAccounts() await loadServerCapabilities(account: account, changeID: changeID) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } - if serverCapabilities != nil { await initializeFolderBrowser() } + if serverCapabilities != nil, let root = folders.first { + requestFolderChildren(root, refresh: true) + } } } @@ -99,7 +105,11 @@ extension NCShareViewModel { capabilitiesTask = Task { @MainActor in await loadServerCapabilities(account: account, changeID: changeID) guard isCurrentCapabilitiesRequest(account: account, changeID: changeID), serverCapabilities != nil else { return } - if folders.isEmpty { await initializeFolderBrowser() } + if folders.isEmpty { + await initializeFolderBrowser() + } else if let root = folders.first { + requestFolderChildren(root, refresh: true) + } } } diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 1c0377e3b6..e5aaf402a4 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -39,7 +39,7 @@ extension NCShareViewModel { isLoading = false } - func initializeFolderBrowser() async { + func initializeFolderBrowser(refreshFromServer: Bool = true) async { let session = sessionStore.getSession() let browserID = folderBrowserID let home = utilityFileSystem.getHomeServer(session: session) @@ -62,7 +62,7 @@ extension NCShareViewModel { guard isCurrentBrowser(browserID, account: session.account) else { return } folderChildren[home] = cached // Make cached destinations usable without waiting for the server refresh. - requestFolderChildren(root) + if refreshFromServer { requestFolderChildren(root) } } func toggleFolder(_ folder: NCShareFolderItem) { diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 532f621279..ee0c879ee4 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -44,11 +44,23 @@ struct NCShareView: View { } } .background(Color(uiColor: .systemGroupedBackground)) - } else if model.isSwitchingAccount && model.isAuthenticated { - ProgressView() - .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) } else { - Color(uiColor: .systemBackground) + VStack(spacing: 0) { + Group { + if !model.alertMessage.isEmpty { + statusError(model.alertMessage) { model.acknowledgeAlert() } + } else { + ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) + .cappedFont(.subheadline, maxDynamicType: .accessibility2) + .padding() + } + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + Spacer(minLength: 0) + } + .background(Color(uiColor: .systemGroupedBackground)) } } .dynamicTypeSize(.xSmall ... .accessibility2) From d45305f3d33226f8c170745e836fe48eb5cad9f1 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 1 Nov 2026 11:36:35 +0100 Subject: [PATCH 22/42] fix: prepare share extension before appearance and avoid loading flicker Separate initial setup and input preparation from one-time activation after appearance. Show the capabilities loading banner only when fetching from the server. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 3 ++- Share/Model/NCShareViewModel.swift | 12 ++++++++++-- Share/NCShareEntryController.swift | 7 ++++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index a9f59f365d..a6b22fa353 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -114,7 +114,6 @@ extension NCShareViewModel { } private func loadServerCapabilities(account: String, changeID: UUID) async { - isLoadingCapabilities = true serverCapabilities = nil capabilitiesErrorMessage = "" defer { if accountChangeID == changeID { isLoadingCapabilities = false } } @@ -123,6 +122,8 @@ extension NCShareViewModel { guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } if capabilities == nil { guard !isOffline else { return } + // Cached capabilities should not briefly insert a loading banner. + isLoadingCapabilities = true 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 { diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 642f9987ac..156c65372d 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -99,6 +99,7 @@ final class NCShareViewModel: NSObject, ObservableObject { var token: Int? var banner: LucidBanner? var sceneIdentifier: String = UUID().uuidString + private var hasActivated = false private var isPresentingNoAccountAlert = false private var isPresentingPasscode = false private var isPasscodeAuthenticated = false @@ -142,17 +143,21 @@ final class NCShareViewModel: NSObject, ObservableObject { let lastVersion = groupDefaults.string(forKey: NCGlobal.shared.udLastVersion) if lastVersion != versionApp { maintenanceMode = true - appear() return } } if let account = sessionStore.getTblAccoun()?.account { + isAuthenticated = !NCPreferences().presentPasscode changeAccount(account) } + prepareInput() + } + func activate() { + guard !hasActivated, !isClosing, let presenter else { return } + hasActivated = true NCNetworking.shared.setupScene(sceneIdentifier: sceneIdentifier, controller: presenter) - if let windowScene = presenter.view.window?.windowScene { banner = LucidBannerRegistry.shared.banner(for: windowScene) } @@ -167,6 +172,9 @@ final class NCShareViewModel: NSObject, ObservableObject { } guard validateAccount() else { return } requestPasscodeIfNeeded() + } + + private func prepareInput() { guard let items = requestContext?.inputItems as? [NSExtensionItem] else { cancel(with: .noFiles) return diff --git a/Share/NCShareEntryController.swift b/Share/NCShareEntryController.swift index 04e0e9105a..45c434a3a1 100644 --- a/Share/NCShareEntryController.swift +++ b/Share/NCShareEntryController.swift @@ -21,8 +21,13 @@ final class NCShareEntryController: UIHostingController { fatalError("This extension is initialized through NSExtensionPrincipalClass.") } + override func viewDidLoad() { + super.viewDidLoad() + model.start(context: extensionContext, presenter: self) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - model.start(context: extensionContext, presenter: self) + model.activate() } } From e12edadc377d50c5dc4f7fa15513192c381b55c9 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 1 Nov 2026 11:51:06 +0100 Subject: [PATCH 23/42] refactor: simplify share extension state and lifecycle Remove redundant state and helpers, tighten action guards, and improve upload cancellation and cleanup. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 23 +-- Share/Model/NCShareViewModel+Folders.swift | 17 +- Share/Model/NCShareViewModel+Upload.swift | 123 +++++++------ Share/Model/NCShareViewModel.swift | 184 ++++++-------------- Share/NCShareEntryController.swift | 10 +- Share/NCShareSessionStore.swift | 11 +- Share/View/NCShareView.swift | 28 ++- 7 files changed, 155 insertions(+), 241 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index a6b22fa353..832a4b51fb 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -8,20 +8,17 @@ import UIKit extension NCShareViewModel { func loadAccounts() { guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, - !isLoadingAccounts, !isShowingInputError else { return } + !isLoadingAccounts else { return } isLoadingAccounts = true accountsTask = Task { @MainActor in - defer { - isLoadingAccounts = false - showInputErrorIfNeeded() - } + defer { isLoadingAccounts = false } 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_") { self.cancel(with: .noAccount) } + showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } return } accountAvatars = Dictionary(uniqueKeysWithValues: storedAccounts.map { account in @@ -46,10 +43,6 @@ extension NCShareViewModel { changeAccount(account) } - func cancelAccountLoading() { - accountsTask?.cancel() - } - func changeAccount(_ account: String) { guard !isClosing, !isPreparingUpload else { return } accountChangeTask?.cancel() @@ -63,8 +56,8 @@ extension NCShareViewModel { let selectedSession = await sessionStore.setSessionAccount(account) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } guard let session = selectedSession, - let tblAccount = sessionStore.getTblAccoun(), tblAccount.account == account else { - showAlert(description: "_no_active_account_") { self.cancel(with: .noAccount) } + 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) @@ -82,11 +75,13 @@ extension NCShareViewModel { autoUploadDirectory = NCManageDatabase.shared.getAccountAutoUploadDirectory( account: account, urlBase: session.urlBase, userId: session.userId) serverUrl = utilityFileSystem.getHomeServer(session: session) - setNavigationBar(navigationTitle: NCBrandOptions.shared.brand) + navigationTitle = NCBrandOptions.shared.brand + let name = tblAccount.displayName.isEmpty ? tblAccount.user : tblAccount.displayName + accountName = tblAccount.alias.isEmpty ? name : name + " - " + tblAccount.alias // Publish the local browser before capabilities can trigger a network request. await initializeFolderBrowser(refreshFromServer: false) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } - showAccountView() + isAvailable = true isSwitchingAccount = false loadAccounts() await loadServerCapabilities(account: account, changeID: changeID) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index e5aaf402a4..99d7289029 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -122,22 +122,13 @@ extension NCShareViewModel { return } serverUrl = folder.serverUrl - metadataFolder = metadata selectedFolder = selected isRecentFolderSelected = fromRecents - setNavigationBar(navigationTitle: selected.name) + navigationTitle = selected.name } } - func recordRecentDestination() { - guard let uploaded = uploadMetadata.first, let folder = selectedFolder, - uploaded.serverUrl == folder.serverUrl else { return } - recentFoldersStore.record(.init( - serverUrl: folder.serverUrl, name: folder.name, ocId: folder.ocId, isEncrypted: folder.isEncrypted), - account: uploaded.account) - } - - private var canUseFolderBrowser: Bool { + var canUseFolderBrowser: Bool { isAuthenticated && !isClosing && isAccountReady && !isLoading && !isPreparingUpload && !isLoadingAccounts } @@ -146,7 +137,7 @@ extension NCShareViewModel { 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(title: "_info_", description: "_e2e_goto_settings_for_enable_") + showAlert(description: "_e2e_goto_settings_for_enable_") return false } return true @@ -159,7 +150,6 @@ extension NCShareViewModel { private func loadFolderChildren(_ folder: NCShareFolderItem, browserID: UUID) async { let session = sessionStore.getSession() guard isCurrentBrowser(browserID, account: session.account) else { return } - loadingFolderURLs.insert(folder.serverUrl) folderLoadErrors[folder.serverUrl] = nil defer { if folderBrowserID == browserID { @@ -213,7 +203,6 @@ extension NCShareViewModel { let updated = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) if folders.first?.serverUrl == folder.serverUrl { folders = [updated] } if selectedFolder?.serverUrl == folder.serverUrl { - metadataFolder = metadata selectedFolder = updated } } diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index dfe5b44e68..4fb4ea72c0 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -12,7 +12,11 @@ import LucidBanner // MARK: - Upload extension NCShareViewModel { - func actionUpload(capabilities: NKCapabilities.Capabilities) { + func upload() { + guard isUploadDestinationReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, + !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } + guard let capabilities = serverCapabilities else { return } + isPreparingUpload = true uploadTask = Task { @MainActor in defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } do { @@ -63,7 +67,7 @@ extension NCShareViewModel { private func prepareUploadPlan(capabilities: NKCapabilities.Capabilities) async throws -> NCShareUploadConfirmation { let session = sessionStore.getSession() let destination = serverUrl - let sourceNames = filesName + let sourceNames = fileNames guard !sourceNames.isEmpty else { throw NCShareRequestError.noFiles } let renamer = FileAutoRenamer(capabilities: capabilities) let requestedNames = try sourceNames.map { name in @@ -112,7 +116,6 @@ extension NCShareViewModel { // 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) - metadataFolder = listing.metadataFolder 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) } @@ -132,7 +135,6 @@ extension NCShareViewModel { guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl else { throw CancellationError() } uploadMetadata.removeAll() replacementETags.removeAll() - counterUploaded = 0 var stagedDirectories: [String] = [] var staged = false defer { @@ -172,8 +174,7 @@ extension NCShareViewModel { await uploadAndExit() } - @MainActor - func uploadAndExit() async { + private 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() @@ -192,11 +193,11 @@ extension NCShareViewModel { self.cancel() }) - for metadata in self.uploadMetadata { + for (index, metadata) in uploadMetadata.enumerated() { guard !Task.isCancelled, !isClosing else { return } // BANNER let payloadUpdate = LucidBannerPayload.Update( - title: NSLocalizedString("_upload_file_", comment: "") + " \(self.counterUploaded + 1) " + NSLocalizedString("_of_", comment: "") + " \(self.filesName.count)", + title: NSLocalizedString("_upload_file_", comment: "") + " \(index + 1) " + NSLocalizedString("_of_", comment: "") + " \(self.fileNames.count)", systemImage: "arrowshape.up.circle", imageAnimation: .breathe, progress: 0) @@ -209,26 +210,35 @@ extension NCShareViewModel { } if error == .success { - recordRecentDestination() + 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: self.token) } else { banner?.update(payload: LucidBannerPayload.Update(subtitle: error?.errorDescription, stage: .error), for: self.token) } guard !Task.isCancelled, !isClosing else { return } - Task { @MainActor in - try? await Task.sleep(for: .seconds(2)) - banner?.dismiss() - cancel() + do { + try await Task.sleep(for: .seconds(2)) + } catch { + return } + guard !isClosing else { return } + banner?.dismiss() + cancel() } - @MainActor - func upload(metadata: tableMetadata) async -> NKError? { + private func upload(metadata: tableMetadata) async -> NKError { let session = sessionStore.getSession() - var error: NKError = .success 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 @@ -244,10 +254,8 @@ extension NCShareViewModel { // E2EE metadata.e2eEncrypted = metadata.isDirectoryE2EE - self.counterUploaded += 1 - if metadata.isDirectoryE2EE { - error = await NCNetworkingE2EEUpload().upload(metadata: metadata, + return await NCNetworkingE2EEUpload().upload(metadata: metadata, session: session, controller: presenter, banner: banner, @@ -257,41 +265,35 @@ extension NCShareViewModel { 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, - overwrite: self.replacementETags[metadata.ocId] != nil, - ifMatch: self.replacementETags[metadata.ocId]) { 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) - } + let results = await NCNetworking.shared.uploadChunkFile( + metadata: metadata, + overwrite: self.replacementETags[metadata.ocId] != nil, + ifMatch: self.replacementETags[metadata.ocId]) { 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 + return results.error } else { let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileName, @@ -303,17 +305,16 @@ extension NCShareViewModel { serverUrlFileName: metadata.serverUrlFileName, creationDate: metadata.creationDate as Date, dateModificationFile: metadata.date as Date, - customHeaders: replacementETags[metadata.ocId].map { ["If-Match": $0] } ?? ["If-None-Match": "*"]) { _ in - } progressHandler: { _, _, fractionCompleted in + customHeaders: replacementETags[metadata.ocId].map { ["If-Match": $0] } ?? ["If-None-Match": "*"], + progressHandler: { _, _, fractionCompleted in Task {@MainActor in self.banner?.update(payload: LucidBannerPayload.Update(progress: fractionCompleted), for: self.token) } - } - error = results.error + }) + return results.error } - return error } } #endif @@ -323,11 +324,9 @@ extension NCShareViewModel { struct NCShareUploadNamePlanner { static func fileNames(for names: [String], existingNames: Set) -> [String] { var occupied = existingNames - var reserved = Set(names) - var assigned: Set = [] + let reserved = Set(names) return names.map { name in - if !occupied.contains(name), !assigned.contains(name) { - assigned.insert(name) + if !occupied.contains(name) { occupied.insert(name) return name } @@ -347,8 +346,6 @@ struct NCShareUploadNamePlanner { candidate = stem + " (\(number))" + suffix } occupied.insert(candidate) - reserved.insert(candidate) - assigned.insert(candidate) return candidate } } diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 156c65372d..6feb4b14c2 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -42,7 +42,6 @@ final class NCShareViewModel: NSObject, ObservableObject { var replacementETags: [String: String] = [:] var isClosing = false @Published var isPreparingInput = false - @Published var isShowingInputError = false @Published var inputErrorMessage = "" @Published var alertMessage = "" private var alertDismissAction: (() -> Void)? @@ -59,7 +58,7 @@ final class NCShareViewModel: NSObject, ObservableObject { var isUploadDestinationReady: Bool { guard let selectedFolder else { return false } - return isAccountReady && !loadingFolderURLs.contains(selectedFolder.serverUrl) && + return isAccountReady && alertMessage.isEmpty && !loadingFolderURLs.contains(selectedFolder.serverUrl) && folderLoadErrors[selectedFolder.serverUrl] == nil } @@ -74,10 +73,6 @@ final class NCShareViewModel: NSObject, ObservableObject { var requestContext: NSExtensionContext? lazy var sessionStore = NCShareSessionStore() var serverUrl = "" - var filesName: [String] { - get { fileNames } - set { fileNames = newValue } - } let inputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("NextcloudShare-" + UUID().uuidString, isDirectory: true) private var inputTask: Task? private var hasStarted = false @@ -85,12 +80,8 @@ final class NCShareViewModel: NSObject, ObservableObject { private var backgroundObserver: NSObjectProtocol? let keyLayout = NCGlobal.shared.layoutViewShareExtension - var metadataFolder: tableMetadata? - var dataSourceTask: URLSessionTask? var autoUploadFileName = "" var autoUploadDirectory = "" - var progress: CGFloat = 0 - var counterUploaded: Int = 0 var uploadMetadata: [tableMetadata] = [] let utilityFileSystem = NCUtilityFileSystem() let utility = NCUtility() @@ -99,7 +90,6 @@ final class NCShareViewModel: NSObject, ObservableObject { var token: Int? var banner: LucidBanner? var sceneIdentifier: String = UUID().uuidString - private var hasActivated = false private var isPresentingNoAccountAlert = false private var isPresentingPasscode = false private var isPasscodeAuthenticated = false @@ -107,7 +97,18 @@ final class NCShareViewModel: NSObject, ObservableObject { func start(context: NSExtensionContext?, presenter: UIViewController) { guard !hasStarted else { return } hasStarted = true - monitorConnection() + let monitor = NWPathMonitor() + connectionMonitor = monitor + 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()) @@ -123,14 +124,14 @@ final class NCShareViewModel: NSObject, ObservableObject { backgroundObserver = NotificationCenter.default.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in - Task { @MainActor [weak self] in self?.cancelAccountLoading() } + 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.cancelAccountLoading() + self.accountsTask?.cancel() guard !self.maintenanceMode, self.validateAccount() else { return } self.resolveUploadConfirmation(nil) self.isPasscodeAuthenticated = false @@ -147,34 +148,10 @@ final class NCShareViewModel: NSObject, ObservableObject { } } - if let account = sessionStore.getTblAccoun()?.account { + if let account = sessionStore.account?.account { isAuthenticated = !NCPreferences().presentPasscode changeAccount(account) } - prepareInput() - } - - func activate() { - guard !hasActivated, !isClosing, let presenter else { return } - hasActivated = true - NCNetworking.shared.setupScene(sceneIdentifier: sceneIdentifier, controller: presenter) - if let windowScene = presenter.view.window?.windowScene { - banner = LucidBannerRegistry.shared.banner(for: windowScene) - } - appear() - } - - private func appear() { - guard !maintenanceMode else { - return showAlert(description: "_version_mismatch_error_") { - self.cancel(with: .versionMismatch) - } - } - guard validateAccount() else { return } - requestPasscodeIfNeeded() - } - - private func prepareInput() { guard let items = requestContext?.inputItems as? [NSExtensionItem] else { cancel(with: .noFiles) return @@ -188,12 +165,11 @@ final class NCShareViewModel: NSObject, ObservableObject { do { let output = try await NCShareLoaderItemProvider.load(items: items, directory: directory) guard let self, !Task.isCancelled else { return } - self.filesName = output.fileNames + 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") - self.showInputErrorIfNeeded() } } catch is CancellationError { // The loader removes its own request directory on cancellation. @@ -201,24 +177,19 @@ final class NCShareViewModel: NSObject, ObservableObject { guard let self, !Task.isCancelled else { return } self.isPreparingInput = false self.inputErrorMessage = NSLocalizedString("_share_import_no_files_", comment: "") + "\n\n" + error.localizedDescription - self.showInputErrorIfNeeded() } } } - private func monitorConnection() { - let monitor = NWPathMonitor() - connectionMonitor = monitor - 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() } + func validateAccountAndRequestPasscode() { + guard !isClosing else { return } + guard !maintenanceMode else { + return showAlert(description: "_version_mismatch_error_") { [weak self] in + self?.cancel(with: .versionMismatch) } } - monitor.start(queue: DispatchQueue(label: "com.nextcloud.share.connection")) + guard validateAccount() else { return } + requestPasscodeIfNeeded() } func retryConnection() { @@ -281,16 +252,9 @@ final class NCShareViewModel: NSObject, ObservableObject { inputDirectory.appendingPathComponent(fileName) } - func showInputErrorIfNeeded() { - // Do not present import errors over the passcode screen. - if isAuthenticated && !isLoadingAccounts && !inputErrorMessage.isEmpty { - isShowingInputError = true - } - } - func acknowledgeInputError() { inputErrorMessage = "" - if filesName.isEmpty { cancel(with: .noFiles) } + if fileNames.isEmpty { cancel(with: .noFiles) } } isolated deinit { @@ -302,7 +266,6 @@ final class NCShareViewModel: NSObject, ObservableObject { accountChangeTask?.cancel() capabilitiesTask?.cancel() capabilitiesRequest?.cancel() - dataSourceTask?.cancel() folderSelectionTask?.cancel() for task in folderTasks.values { task.cancel() } for request in folderRequests.values { request.cancel() } @@ -316,7 +279,7 @@ final class NCShareViewModel: NSObject, ObservableObject { return showNoAccountAlert() } - if let account = sessionStore.getTblAccoun()?.account, + if let account = sessionStore.account?.account, NCManageDatabase.shared.getTableAccount(account: account) != nil { return true } @@ -325,7 +288,6 @@ final class NCShareViewModel: NSObject, ObservableObject { return showNoAccountAlert() } - clearAccountView() changeAccount(activeAccount.account) return true } @@ -334,8 +296,8 @@ final class NCShareViewModel: NSObject, ObservableObject { clearAccountView() if !isPresentingNoAccountAlert { isPresentingNoAccountAlert = true - showAlert(description: "_no_active_account_") { - self.cancel(with: .noAccount) + showAlert(description: "_no_active_account_") { [weak self] in + self?.cancel(with: .noAccount) } } return false @@ -347,23 +309,20 @@ final class NCShareViewModel: NSObject, ObservableObject { serverCapabilities = nil isLoadingCapabilities = false capabilitiesErrorMessage = "" - dataSourceTask?.cancel() - dataSourceTask = nil - metadataFolder = nil isAvailable = false resetFolderBrowser() accountName = "" navigationTitle = NCBrandOptions.shared.brand } - func showAccountView() { - isAvailable = true - } - func cancel(with error: NCShareRequestError? = nil) { + guard !isClosing else { return } isClosing = true + alertDismissAction = nil connectionMonitor?.cancel() - cancelAccountLoading() + for request in thumbnailRequests.values { QLThumbnailGenerator.shared.cancel(request) } + thumbnailRequests.removeAll() + fileThumbnails.removeAll() uploadConfirmation = nil pendingUploadConfirmation = nil uploadChoice = nil @@ -373,7 +332,6 @@ final class NCShareViewModel: NSObject, ObservableObject { accountChangeTask?.cancel() capabilitiesTask?.cancel() capabilitiesRequest?.cancel() - dataSourceTask?.cancel() folderSelectionTask?.cancel() for task in folderTasks.values { task.cancel() } for request in folderRequests.values { request.cancel() } @@ -385,7 +343,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } } - func showAlert(title: String = "_error_", description: String, onDismiss: (() -> Void)? = nil) { + func showAlert(description: String, onDismiss: (() -> Void)? = nil) { alertMessage = NSLocalizedString(description.replacingOccurrences(of: "\t", with: "\n"), comment: "") alertDismissAction = onDismiss } @@ -397,20 +355,10 @@ final class NCShareViewModel: NSObject, ObservableObject { action?() } - func setNavigationBar(navigationTitle: String) { - self.navigationTitle = navigationTitle - guard let account = sessionStore.getTblAccoun() else { return } - let name = account.displayName.isEmpty ? account.user : account.displayName - accountName = account.alias.isEmpty ? name : name + " - " + account.alias - } - - func setCommandView() { - if filesName.isEmpty { cancel(with: .noFiles) } - } - func createFolder() { let session = sessionStore.getSession() - guard isAccountReady, let folder = selectedFolder, !loadingFolderURLs.contains(folder.serverUrl) else { return } + guard canUseFolderBrowser, isUploadDestinationReady, !isPreparingInput, inputErrorMessage.isEmpty, + let folder = selectedFolder else { return } guard let capabilities = serverCapabilities else { return } let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in guard let self else { return } @@ -419,79 +367,63 @@ final class NCShareViewModel: NSObject, ObservableObject { self.expandedFolderURLs.insert(folder.serverUrl) self.requestFolderChildren(folder, refresh: true) } else { - self.showAlert(title: "_error_createsubfolders_upload_", description: error.errorDescription) + self.showAlert(description: error.errorDescription) } } presenter?.present(alert, animated: true) } - func showRenameFileDialog(named fileName: String, account: String) { - guard let capabilities = serverCapabilities else { return } - let alert = UIAlertController.renameFile(fileName: fileName, capabilities: capabilities, account: account) { [self] newFileName in - renameFile(oldName: fileName, newName: newFileName, account: account) + 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) } - func renameFile(oldName: String, newName: String, account: String) { - guard let fileIx = self.filesName.firstIndex(of: oldName), - !self.filesName.contains(newName), + 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(title: "_single_file_conflict_title_", description: "'\(oldName)' -> '\(newName)'") + return showAlert(description: "'\(oldName)' -> '\(newName)'") } discardThumbnail(named: oldName) - filesName[fileIx] = newName + fileNames[fileIx] = newName } func removeFile(named fileName: String) { - guard let index = self.filesName.firstIndex(of: fileName) else { - return showAlert(title: "_file_not_found_", description: fileName) + 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.filesName.remove(at: index) - if self.filesName.isEmpty { + self.fileNames.remove(at: index) + if self.fileNames.isEmpty { cancel(with: NCShareRequestError.noFiles) - } else { - self.setCommandView() } } - func rename(_ fileName: String) { - showRenameFileDialog(named: fileName, account: sessionStore.getSession().account) - } - - func remove(_ fileName: String) { - removeFile(named: fileName) - } - - func upload() { - guard isUploadDestinationReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, - !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } - guard let capabilities = serverCapabilities else { return } - isPreparingUpload = true - actionUpload(capabilities: capabilities) - } } extension NCShareViewModel { /// Require authentication once per foreground session without reopening after dismissal. private func requestPasscodeIfNeeded() { - if !NCPreferences().presentPasscode { + if !NCPreferences().presentPasscode || isPasscodeAuthenticated { isAuthenticated = true - showInputErrorIfNeeded() return } isAuthenticated = false - isShowingInputError = false - guard NCPreferences().presentPasscode, - !isPasscodeAuthenticated, - !isPresentingPasscode, + guard !isPresentingPasscode, let presenter else { return } isPresentingPasscode = true // Embed in the content controller; the helper hides the outer navigation bar. @@ -503,7 +435,7 @@ extension NCShareViewModel { self.isPasscodeAuthenticated = true if self.validateAccount() { self.isAuthenticated = true - self.showInputErrorIfNeeded() + if self.accounts.isEmpty { self.loadAccounts() } } case .cancelled, .reset, .configured, .disabled: self.cancel(with: .noAccount) diff --git a/Share/NCShareEntryController.swift b/Share/NCShareEntryController.swift index 45c434a3a1..e471c06157 100644 --- a/Share/NCShareEntryController.swift +++ b/Share/NCShareEntryController.swift @@ -3,11 +3,13 @@ // 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() { @@ -28,6 +30,12 @@ final class NCShareEntryController: UIHostingController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - model.activate() + guard !hasAppeared, !model.isClosing else { return } + hasAppeared = true + NCNetworking.shared.setupScene(sceneIdentifier: model.sceneIdentifier, controller: self) + if let windowScene = view.window?.windowScene { + model.banner = LucidBannerRegistry.shared.banner(for: windowScene) + } + model.validateAccountAndRequestPasscode() } } diff --git a/Share/NCShareSessionStore.swift b/Share/NCShareSessionStore.swift index 238d9de649..90547c2014 100644 --- a/Share/NCShareSessionStore.swift +++ b/Share/NCShareSessionStore.swift @@ -7,25 +7,22 @@ import NextcloudKit @MainActor final class NCShareSessionStore { - private var tblAccount: tableAccount? = NCManageDatabase.shared.getActiveTableAccount() + 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 } - tblAccount = selected + self.account = selected return getSession() } func getSession() -> NCSession.Session { - if let tblAccount { - return NCSession.Session(account: tblAccount.account, urlBase: tblAccount.urlBase, user: tblAccount.user, userId: tblAccount.userId) + 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: "") } - func getTblAccoun() -> tableAccount? { - tblAccount - } } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index ee0c879ee4..a271996aad 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -38,7 +38,13 @@ struct NCShareView: View { } else { VStack(spacing: 0) { folderList() - commands() + VStack(spacing: 10) { + fileList() + uploadButton + } + .padding(.top, 10) + .padding(.bottom, 16) + .background(Color(uiColor: .systemGroupedBackground)) } } } @@ -97,7 +103,7 @@ struct NCShareView: View { private var statusView: some View { if !model.alertMessage.isEmpty { statusError(model.alertMessage) { model.acknowledgeAlert() } - } else if !model.inputErrorMessage.isEmpty && model.isShowingInputError { + } else if !model.inputErrorMessage.isEmpty && model.isAuthenticated && !model.isLoadingAccounts { statusError(model.inputErrorMessage) { model.acknowledgeInputError() } } else if model.isOffline { VStack(spacing: 8) { @@ -215,8 +221,8 @@ struct NCShareView: View { } .buttonStyle(.borderless) .tint(.blue) - .disabled(model.isPreparingInput || !model.inputErrorMessage.isEmpty || - (model.selectedFolder.map { model.loadingFolderURLs.contains($0.serverUrl) } ?? true)) + .disabled(!model.canUseFolderBrowser || !model.isUploadDestinationReady || + model.isPreparingInput || !model.inputErrorMessage.isEmpty) } .textCase(nil) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) @@ -352,10 +358,10 @@ struct NCShareView: View { set: { if !$0 { fileActions = nil } } ), titleVisibility: .visible) { Button(NSLocalizedString("_rename_file_", comment: "")) { - model.rename(fileName) + model.showRenameFileDialog(named: fileName) } Button(NSLocalizedString("_remove_file_", comment: ""), role: .destructive) { - model.remove(fileName) + model.removeFile(named: fileName) } Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) { } } @@ -399,16 +405,6 @@ struct NCShareView: View { model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } - private func commands() -> some View { - VStack(spacing: 10) { - fileList() - uploadButton - } - .padding(.top, 10) - .padding(.bottom, 16) - .background(Color(uiColor: .systemGroupedBackground)) - } - } #if DEBUG && SHARE_PREVIEWS From bc6897c762e1e074adc924cdcd91def33237eb0c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 1 Nov 2026 11:56:54 +0100 Subject: [PATCH 24/42] docs: clarify Share extension lifecycle and upload flow Document account switching, folder loading, attachment import and upload conflict handling. Make self captures explicit in account tasks. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 10 ++++++++-- Share/Model/NCShareViewModel+Folders.swift | 5 +++++ Share/Model/NCShareViewModel+Upload.swift | 16 ++++++++++++++++ Share/Model/NCShareViewModel.swift | 12 ++++++++++++ Share/NCShareEntryController.swift | 3 +++ Share/NCShareLoaderItemProvider.swift | 3 +++ Share/View/NCShareView.swift | 2 ++ 7 files changed, 49 insertions(+), 2 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 832a4b51fb..0483542d02 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -10,7 +10,7 @@ extension NCShareViewModel { guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, !isLoadingAccounts else { return } isLoadingAccounts = true - accountsTask = Task { @MainActor in + accountsTask = Task { @MainActor [self] in defer { isLoadingAccounts = false } let selected = sessionStore.getSession().account let storedAccounts = await NCManageDatabase.shared.getAllAccountOrderAliasAsync() @@ -43,15 +43,19 @@ extension NCShareViewModel { 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() serverUrl = "" - accountChangeTask = Task { @MainActor in + 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 } @@ -108,6 +112,8 @@ extension NCShareViewModel { } } + /// 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 { serverCapabilities = nil capabilitiesErrorMessage = "" diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 99d7289029..ad1703d58a 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -6,6 +6,7 @@ import UIKit import NextcloudKit extension NCShareViewModel { + /// Flattens expanded branches into rows; collapsed children stay cached. var visibleFolderRows: [NCShareFolderRow] { var rows: [NCShareFolderRow] = [] var visited: Set = [] @@ -81,6 +82,8 @@ extension NCShareViewModel { 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, !loadingFolderURLs.contains(folder.serverUrl), refresh || !loadedFolderURLs.contains(folder.serverUrl) else { return } @@ -92,6 +95,8 @@ extension NCShareViewModel { } } + /// Validates the destination on the server before changing the selection. + /// 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() diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 4fb4ea72c0..3ba749a20b 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -12,6 +12,8 @@ import LucidBanner // MARK: - Upload extension NCShareViewModel { + /// Upload flow: inspect the destination, resolve name conflicts if needed, + /// stage local copies, then transfer. Keep editing disabled throughout this flow. func upload() { guard isUploadDestinationReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } @@ -37,6 +39,8 @@ extension NCShareViewModel { } } + // 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?) { guard pendingUploadConfirmation != nil else { return } uploadChoice = choice @@ -64,6 +68,9 @@ extension NCShareViewModel { } } + /// 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() let destination = serverUrl @@ -92,6 +99,8 @@ extension NCShareViewModel { 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() @@ -128,6 +137,8 @@ extension NCShareViewModel { 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) } @@ -265,6 +276,9 @@ extension NCShareViewModel { banner?.update(payload: LucidBannerPayload.Update(systemImage: "gearshape.arrow.triangle.2.circlepath", imageAnimation: .rotate), for: self.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: self.replacementETags[metadata.ocId] != nil, @@ -300,6 +314,8 @@ extension NCShareViewModel { 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, diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 6feb4b14c2..1b8f452db0 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -9,6 +9,8 @@ import Network @preconcurrency import QuickLookThumbnailing import UniformTypeIdentifiers +/// Owns one share request. Accounts, folder browsing and uploads are implemented +/// in the corresponding extensions; input files remain local until upload staging. @MainActor final class NCShareViewModel: NSObject, ObservableObject { @Published var folders: [NCShareFolderItem] = [] @@ -23,6 +25,7 @@ final class NCShareViewModel: NSObject, ObservableObject { 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) @@ -33,9 +36,12 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var isLoading = false @Published private(set) var isOffline = false private var connectionMonitor: NWPathMonitor? + // Local account/browser state is ready. Authentication and upload readiness + // are separate: cached folders may be displayed before capabilities arrive. @Published var isAvailable = false @Published var isPreparingUpload = false @Published var uploadConfirmation: NCShareUploadConfirmation? + // Keep the plan after SwiftUI clears the sheet binding, until onDismiss runs. var pendingUploadConfirmation: NCShareUploadConfirmation? var uploadChoice: NCShareUploadChoice? var uploadTask: Task? @@ -94,11 +100,15 @@ final class NCShareViewModel: NSObject, ObservableObject { private var isPresentingPasscode = false private var isPasscodeAuthenticated = false + /// 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 !hasStarted else { return } hasStarted = true 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 @@ -315,6 +325,8 @@ final class NCShareViewModel: NSObject, ObservableObject { navigationTitle = NCBrandOptions.shared.brand } + /// 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 diff --git a/Share/NCShareEntryController.swift b/Share/NCShareEntryController.swift index e471c06157..029d947a3d 100644 --- a/Share/NCShareEntryController.swift +++ b/Share/NCShareEntryController.swift @@ -25,11 +25,14 @@ final class NCShareEntryController: UIHostingController { 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 NCNetworking.shared.setupScene(sceneIdentifier: model.sceneIdentifier, controller: self) diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift index be7b69a16c..bbca5be0a5 100644 --- a/Share/NCShareLoaderItemProvider.swift +++ b/Share/NCShareLoaderItemProvider.swift @@ -129,6 +129,9 @@ final class NCShareLoaderItemProvider { 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) } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index a271996aad..eed5d42343 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -99,6 +99,8 @@ struct NCShareView: View { } } + // 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 { From 6ab1418b779dc028d3e57c9288aa856178add82b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 1 Nov 2026 12:02:15 +0100 Subject: [PATCH 25/42] refactor: remove unused sources and resources from Share extension Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 04a9b109c4..b631b82d84 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -171,7 +171,6 @@ 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 */; }; @@ -284,7 +283,6 @@ 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 */; }; @@ -384,7 +382,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 */; }; @@ -413,14 +410,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 */; }; @@ -450,8 +445,6 @@ 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 */; }; @@ -718,7 +711,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 */; }; @@ -741,7 +733,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 */; }; @@ -840,7 +831,6 @@ 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 */; }; F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F79FFB272A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; @@ -919,7 +909,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 */; }; @@ -957,7 +946,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 */; }; @@ -1009,7 +997,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 */; }; @@ -1075,7 +1062,6 @@ F7FDFF6E2E437E55000D7688 /* NCAccountSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF572E437E55000D7688 /* NCAccountSettingsView.swift */; }; F7FDFF6F2E437E55000D7688 /* NCAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FDFF592E437E55000D7688 /* NCAccount.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 */ @@ -4477,7 +4463,6 @@ F7E4022C2BA85D1D007E5609 /* PrivacyInfo.xcprivacy in Resources */, AA517B822D660EFE00F8D37C /* Localizable.stringsdict in Resources */, F7145A231D12E3B700CAFEEC /* Localizable.strings in Resources */, - F746EC51273906C40052598D /* NCViewCertificateDetails.storyboard in Resources */, F76C26A62850D3A500E42BDF /* Images.xcassets in Resources */, F700222D1EC479840080073F /* Custom.xcassets in Resources */, ); @@ -4824,8 +4809,6 @@ 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 */, F7A0D1362591FBC5008F8A13 /* String+Extension.swift in Sources */, @@ -4834,7 +4817,6 @@ F74B6D982A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */, F7CF06872E1127460063AD04 /* NCManageDatabase+CreateMetadata.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 */, @@ -4842,9 +4824,6 @@ F7E98C1727E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */, F76D84B63063C2170081F798 /* NCManageDatabase+Albums.swift in Sources */, F76340F82EBDE9760056F538 /* NCManageDatabaseCore.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 */, @@ -4854,14 +4833,10 @@ 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 */, F7D61EBF2EBF1878007F865B /* NCManageDatabase+Tag.swift in Sources */, @@ -4874,7 +4849,6 @@ 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 */, F702F2D025EE5B5C008F8E80 /* NCGlobal.swift in Sources */, F72437802C10B92400C7C68D /* NCSharePermissions.swift in Sources */, @@ -4907,9 +4881,7 @@ F3F442F12DDE2A7700FD701F /* NCMetadataPermissions.swift in Sources */, F73EF7E22B02266D0087E6E9 /* NCManageDatabase+Trash.swift in Sources */, F77DD6AB2C5CC093009448FB /* NCSession.swift in Sources */, - F74B91E62F51D4170050813D /* InfoBannerView.swift in Sources */, F749B654297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */, - F7DA0F662F66AA0D0033020C /* ShowBanner.swift in Sources */, F73EF7C22B02250B0087E6E9 /* NCManageDatabase+GPS.swift in Sources */, AB1028260000000000030000 /* NCShareEntryController.swift in Sources */, AB1028260000000000030002 /* NCShareViewModel+Accounts.swift in Sources */, From f0a1ca2e053779080d107368cb063da767b63b02 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 7 Nov 2026 09:32:17 +0100 Subject: [PATCH 26/42] fix: improve Share extension browsing and filename display Preserve folder content during account switches and select and reveal newly created folders. Unify loading feedback, keep file extensions visible, and strip bidirectional controls from displayed filenames. Tie folder dialog validation to the text field lifetime to avoid retaining alerts. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 7 ++ Share/Model/NCShareViewModel+Accounts.swift | 6 +- Share/Model/NCShareViewModel+Folders.swift | 36 ++++---- Share/Model/NCShareViewModel.swift | 70 ++++++++++++---- Share/View/NCShareAccountMenu.swift | 9 +- Share/View/NCShareFilenameView.swift | 60 ++++++++++++++ Share/View/NCShareUploadConflictView.swift | 6 +- Share/View/NCShareView.swift | 82 ++++++++++--------- .../UIAlertController+Extension.swift | 20 +++-- .../en.lproj/Localizable.strings | 1 + 10 files changed, 206 insertions(+), 91 deletions(-) create mode 100644 Share/View/NCShareFilenameView.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b631b82d84..bd270af763 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -81,6 +81,7 @@ 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 */; }; + A71107260000000000000101 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.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 */; }; @@ -92,6 +93,7 @@ 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 */; }; + A71107260000000000000102 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.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 */; }; @@ -1397,6 +1399,7 @@ 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 = ""; }; + A71107260000000000000100 /* NCShareFilenameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareFilenameView.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 = ""; }; @@ -2353,6 +2356,7 @@ AB1028260000000000020009 /* NCShareView.swift */, AB1028260000000000060000 /* NCShareAccountMenu.swift */, AB1028260000000000050001 /* NCShareUploadConflictView.swift */, + A71107260000000000000100 /* NCShareFilenameView.swift */, ); path = View; sourceTree = ""; @@ -4891,6 +4895,7 @@ AB1028260000000000030008 /* NCShareSessionStore.swift in Sources */, AB1028260000000000030009 /* NCShareView.swift in Sources */, AB1028260000000000050101 /* NCShareUploadConflictView.swift in Sources */, + A71107260000000000000101 /* NCShareFilenameView.swift in Sources */, AB1028260000000000060001 /* NCShareAccountMenu.swift in Sources */, AB102826000000000003000A /* NCShareViewModel.swift in Sources */, F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */, @@ -5032,6 +5037,7 @@ AB1028260000000000080005 /* NCShareSessionStore.swift in Sources */, AB1028260000000000080006 /* NCShareAccountMenu.swift in Sources */, AB1028260000000000080007 /* NCShareUploadConflictView.swift in Sources */, + A71107260000000000000102 /* NCShareFilenameView.swift in Sources */, AB1028260000000000080008 /* NCShareView.swift in Sources */, F76BED77306D0000002E1442 /* NCLocalDatabase.swift in Sources */, F76BED78306D0000002E1442 /* NCLocalDatabaseMigrator.swift in Sources */, @@ -6435,6 +6441,7 @@ NCShareSessionStore.swift, NCShareAccountMenu.swift, NCShareUploadConflictView.swift, + NCShareFilenameView.swift, NCShareView.swift, ); GCC_SYMBOLS_PRIVATE_EXTERN = YES; diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index 0483542d02..b0e359562f 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -53,7 +53,7 @@ extension NCShareViewModel { let changeID = UUID() accountChangeID = changeID isSwitchingAccount = true - clearAccountView() + clearAccountView(preservingContent: true) serverUrl = "" accountChangeTask = Task { @MainActor [self] in defer { if accountChangeID == changeID { isSwitchingAccount = false } } @@ -79,7 +79,6 @@ extension NCShareViewModel { autoUploadDirectory = NCManageDatabase.shared.getAccountAutoUploadDirectory( account: account, urlBase: session.urlBase, userId: session.userId) serverUrl = utilityFileSystem.getHomeServer(session: session) - navigationTitle = NCBrandOptions.shared.brand let name = tblAccount.displayName.isEmpty ? tblAccount.user : tblAccount.displayName accountName = tblAccount.alias.isEmpty ? name : name + " - " + tblAccount.alias // Publish the local browser before capabilities can trigger a network request. @@ -115,6 +114,7 @@ extension NCShareViewModel { /// 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 } } @@ -123,8 +123,6 @@ extension NCShareViewModel { guard isCurrentCapabilitiesRequest(account: account, changeID: changeID) else { return } if capabilities == nil { guard !isOffline else { return } - // Cached capabilities should not briefly insert a loading banner. - isLoadingCapabilities = true 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 { diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index ad1703d58a..c9f9d2c813 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -21,19 +21,23 @@ extension NCShareViewModel { return rows } - func resetFolderBrowser() { + 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() - folders = [] - recentFolders = [] - selectedFolder = nil - isRecentFolderSelected = false - folderChildren = [:] - expandedFolderURLs = [] + // Keep the previous snapshot visible but disabled while the account changes. + if !preservingContent { + folders = [] + recentFolders = [] + selectedFolder = nil + isRecentFolderSelected = false + folderChildren = [:] + expandedFolderURLs = [] + } + folderScrollTarget = nil loadingFolderURLs = [] loadedFolderURLs = [] folderLoadErrors = [:] @@ -47,10 +51,7 @@ extension NCShareViewModel { let root = NCShareFolderItem( ocId: "", name: NSLocalizedString("_share_home_folder_", comment: ""), serverUrl: home, path: "/", image: NCImageCache.shared.getFolder(account: session.account), isEncrypted: false) - folders = [root] - selectedFolder = root - expandedFolderURLs = [home] - recentFolders = recentFoldersStore.folders(account: session.account).filter { + let recent = recentFoldersStore.folders(account: session.account).filter { $0.serverUrl == home || $0.serverUrl.hasPrefix(home + "/") }.map { item in NCShareFolderItem( @@ -61,7 +62,13 @@ extension NCShareViewModel { } let cached = await cachedFolderChildren(at: home, parent: nil, session: session) guard isCurrentBrowser(browserID, account: session.account) else { return } - folderChildren[home] = cached + // Publish the replacement snapshot together, without an empty intermediate list. + folders = [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) } } @@ -95,7 +102,7 @@ extension NCShareViewModel { } } - /// Validates the destination on the server before changing the selection. + /// 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 } @@ -129,7 +136,8 @@ extension NCShareViewModel { serverUrl = folder.serverUrl selectedFolder = selected isRecentFolderSelected = fromRecents - navigationTitle = selected.name + expandedFolderURLs.insert(selected.serverUrl) + requestFolderChildren(selected) } } diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 1b8f452db0..4be624aaae 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -17,6 +17,7 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var recentFolders: [NCShareFolderItem] = [] @Published var selectedFolder: NCShareFolderItem? @Published var isRecentFolderSelected = false + @Published var folderScrollTarget: String? @Published var folderChildren: [String: [NCShareFolderItem]] = [:] @Published var expandedFolderURLs: Set = [] @Published var loadingFolderURLs: Set = [] @@ -51,7 +52,6 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var inputErrorMessage = "" @Published var alertMessage = "" private var alertDismissAction: (() -> Void)? - @Published var navigationTitle = NCBrandOptions.shared.brand @Published var accountName = "" @Published var accounts: [NCShareAccountItem] = [] @Published var accountAvatars: [String: UIImage] = [:] @@ -313,16 +313,17 @@ final class NCShareViewModel: NSObject, ObservableObject { return false } - func clearAccountView() { + func clearAccountView(preservingContent: Bool = false) { capabilitiesTask?.cancel() capabilitiesRequest?.cancel() serverCapabilities = nil isLoadingCapabilities = false capabilitiesErrorMessage = "" - isAvailable = false - resetFolderBrowser() - accountName = "" - navigationTitle = NCBrandOptions.shared.brand + resetFolderBrowser(preservingContent: preservingContent) + if !preservingContent { + isAvailable = false + accountName = "" + } } /// Completes or cancels the extension request once and stops its pending work. @@ -371,18 +372,51 @@ final class NCShareViewModel: NSObject, ObservableObject { let session = sessionStore.getSession() guard canUseFolderBrowser, isUploadDestinationReady, !isPreparingInput, inputErrorMessage.isEmpty, let folder = selectedFolder else { return } - guard let capabilities = serverCapabilities else { return } - let alert = UIAlertController.createFolderWith(serverUrl: folder.serverUrl, session: session, capabilities: capabilities) { [weak self] error in - guard let self else { return } - guard !self.isClosing, self.sessionStore.getSession().account == session.account else { return } - if error == .success { - self.expandedFolderURLs.insert(folder.serverUrl) - self.requestFolderChildren(folder, refresh: true) - } else { - self.showAlert(description: error.errorDescription) - } - } - presenter?.present(alert, animated: true) + guard let capabilities = serverCapabilities, + let presenter, presenter.viewIfLoaded?.window != nil, + presenter.presentedViewController == nil, + !presenter.isBeingDismissed else { return } + let browserID = folderBrowserID + let alert = UIAlertController.createFolderWith( + serverUrl: folder.serverUrl, session: session, + onCreated: { [weak self] name in + guard let self, !self.isClosing, self.folderBrowserID == browserID else { 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.folders.first else { return } + while true { + guard !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID else { return } + self.expandedFolderURLs.insert(branch.serverUrl) + if let task = self.folderTasks[branch.serverUrl] { await task.value } + guard !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID else { return } + self.requestFolderChildren(branch, refresh: branch.serverUrl == folder.serverUrl) + if let task = self.folderTasks[branch.serverUrl] { await task.value } + guard !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID, + 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.serverUrl = created.serverUrl + self.isRecentFolderSelected = false + self.expandedFolderURLs.insert(created.serverUrl) + self.requestFolderChildren(created) + self.folderScrollTarget = created.serverUrl + } + }, capabilities: capabilities, completion: { [weak self] error in + guard let self, !self.isClosing, self.folderBrowserID == browserID else { return } + if error != .success { self.showAlert(description: error.errorDescription) } + }) + presenter.present(alert, animated: true) } func showRenameFileDialog(named fileName: String) { diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift index b9a623efa6..18585408d2 100644 --- a/Share/View/NCShareAccountMenu.swift +++ b/Share/View/NCShareAccountMenu.swift @@ -26,12 +26,11 @@ struct NCShareAccountMenu: View { } .pickerStyle(.inline) } label: { - HStack(spacing: 8) { - avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") - Text(verbatim: model.accountName) - .lineLimit(1) - } + avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") + .frame(minWidth: 44, minHeight: 44) } + .accessibilityLabel(NSLocalizedString("_account_select_", comment: "")) + .accessibilityValue(Text(verbatim: model.accountName)) .buttonStyle(.plain) .dynamicTypeSize(.xSmall ... .accessibility2) .disabled(model.accounts.count < 2) 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/NCShareUploadConflictView.swift b/Share/View/NCShareUploadConflictView.swift index 3a222c0c90..ff52326329 100644 --- a/Share/View/NCShareUploadConflictView.swift +++ b/Share/View/NCShareUploadConflictView.swift @@ -15,15 +15,15 @@ struct NCShareUploadConflictView: View { Section { Text(confirmation.isBatch ? NSLocalizedString("_share_conflict_batch_message_", comment: "") - : String(format: NSLocalizedString("_share_conflict_single_message_", comment: ""), confirmation.items[0].sourceName)) + : String(format: NSLocalizedString("_share_conflict_single_message_", comment: ""), NCShareFilenameView.displayName(confirmation.items[0].sourceName))) } Section(NSLocalizedString("_share_conflict_new_names_", comment: "")) { ForEach(confirmation.renamedItems, id: \.sourceName) { item in VStack(alignment: .leading, spacing: 4) { - Text(verbatim: item.sourceName) + NCShareFilenameView(name: item.sourceName) .cappedFont(.caption, maxDynamicType: .accessibility2) .foregroundStyle(.secondary) - Text(verbatim: item.fileName) + NCShareFilenameView(name: item.fileName) } } } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index eed5d42343..286f0f9434 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -9,6 +9,11 @@ struct NCShareView: View { @ObservedObject var model: NCShareViewModel @State private var fileActions: String? + private var isRefreshing: Bool { + model.isLoading || model.isSwitchingAccount || model.isLoadingAccounts || + model.isLoadingCapabilities || !model.loadingFolderURLs.isEmpty + } + var body: some View { NavigationStack { Group { @@ -74,7 +79,7 @@ struct NCShareView: View { .sheet(item: $model.uploadConfirmation, onDismiss: { model.uploadConfirmationDismissed() }) { confirmation in NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) } - .navigationTitle(model.navigationTitle) + .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) @@ -84,9 +89,9 @@ struct NCShareView: View { HStack(spacing: 8) { NCShareAccountMenu(model: model) .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) - if model.isLoading || !model.loadingFolderURLs.isEmpty { + if isRefreshing { ProgressView() - .accessibilityLabel(NSLocalizedString(model.isLoading ? "_share_checking_folder_" : "_share_loading_folders_", comment: "")) + .accessibilityLabel(NSLocalizedString("_request_in_progress_", comment: "")) .padding(.trailing, 8) } } @@ -133,9 +138,7 @@ struct NCShareView: View { Button(NSLocalizedString("_share_retry_folder_", comment: "")) { model.retryFolder(row.folder) } } .padding() - } else if model.isLoadingCapabilities { - ProgressView(NSLocalizedString("_share_capabilities_loading_", comment: "")) - .padding() + } else if model.isPreparingInput { ProgressView(NSLocalizedString("_share_import_preparing_", comment: "")) .padding() @@ -158,42 +161,41 @@ struct NCShareView: View { createFolderHeader .padding(.horizontal, horizontalInset) } - 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) - if model.expandedFolderURLs.contains(row.folder.serverUrl) { - if model.folderLoadErrors[row.folder.serverUrl] == nil, - model.loadedFolderURLs.contains(row.folder.serverUrl), - model.folderChildren[row.folder.serverUrl]?.isEmpty == true { - Text(NSLocalizedString("_files_no_folders_", comment: "")) - .cappedFont(.caption, maxDynamicType: .accessibility2) - .foregroundStyle(.secondary) - .padding(.leading, CGFloat(min(row.depth + 1, 8)) * 16) + 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 + } } - .listStyle(.plain) - .contentMargins(.vertical, 0, for: .scrollContent) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 0) - .background(Color(uiColor: .secondarySystemGroupedBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, horizontalInset) } .frame(maxHeight: .infinity) .clipped() .background(Color(uiColor: .systemGroupedBackground)) - .disabled(model.isLoading || model.isPreparingUpload || !model.isAccountReady || model.isLoadingAccounts) + .opacity(isRefreshing ? 0.45 : 1) + .disabled(isRefreshing || model.isPreparingUpload || !model.isAccountReady) } private func folderListTitle(_ key: String) -> some View { @@ -223,7 +225,7 @@ struct NCShareView: View { } .buttonStyle(.borderless) .tint(.blue) - .disabled(!model.canUseFolderBrowser || !model.isUploadDestinationReady || + .disabled(isRefreshing || !model.canUseFolderBrowser || !model.isUploadDestinationReady || model.isPreparingInput || !model.inputErrorMessage.isEmpty) } .textCase(nil) @@ -327,7 +329,7 @@ struct NCShareView: View { } label: { HStack { Label { - Text(verbatim: fileName).lineLimit(2) + NCShareFilenameView(name: fileName) } icon: { Group { if let thumbnail = model.fileThumbnails[fileName] { @@ -345,7 +347,8 @@ struct NCShareView: View { } .foregroundStyle(.primary) Spacer(minLength: 0) - Image(systemName: "ellipsis.circle") + Image(systemName: "ellipsis") + .cappedFont(.title2, maxDynamicType: .accessibility2) .foregroundStyle(Color.accentColor) .frame(width: 50, height: 50) .accessibilityHidden(true) @@ -355,7 +358,7 @@ struct NCShareView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .confirmationDialog(Text(verbatim: fileName), isPresented: Binding( + .confirmationDialog(Text(verbatim: NCShareFilenameView.displayName(fileName)), isPresented: Binding( get: { fileActions == fileName }, set: { if !$0 { fileActions = nil } } ), titleVisibility: .visible) { @@ -380,7 +383,8 @@ struct NCShareView: View { .background(Color(uiColor: .secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, horizontalInset) - .disabled(model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + .opacity(isRefreshing ? 0.45 : 1) + .disabled(isRefreshing || model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || !model.inputErrorMessage.isEmpty) } private var uploadButton: some View { @@ -402,7 +406,7 @@ struct NCShareView: View { .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: 12)) .padding(.horizontal, 16) - .disabled(model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || + .disabled(isRefreshing || model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } diff --git a/iOSClient/Extensions/UIAlertController+Extension.swift b/iOSClient/Extensions/UIAlertController+Extension.swift index 500f3973eb..5b89c0b7ff 100644 --- a/iOSClient/Extensions/UIAlertController+Extension.swift +++ b/iOSClient/Extensions/UIAlertController+Extension.swift @@ -18,13 +18,14 @@ extension UIAlertController { session: NCSession.Session, markE2ee: Bool = false, sceneIdentifier: String? = nil, + onCreated: ((_ folderName: String) -> Void)? = nil, capabilities: NKCapabilities.Capabilities, completion: ((_ error: NKError) -> Void)? = nil) -> 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 { @@ -51,6 +52,7 @@ extension UIAlertController { banner.dismiss() } } + if error == .success { onCreated?(fileNameFolder) } completion?(error) } else { if let banner { @@ -68,12 +70,14 @@ extension UIAlertController { let error = await NCNetworkingE2EECreateFolder().createFolder(fileName: fileNameFolder, serverUrl: serverUrl, sceneIdentifier: sceneIdentifier, session: session) + if error == .success { onCreated?(fileNameFolder) } completion?(error) } } else { #if EXTENSION Task { let error = await NCNetworking.shared.createFolder(fileName: fileNameFolder, serverUrl: serverUrl, overwrite: false, session: session) + if error == .success { onCreated?(fileNameFolder) } completion?(error) } #else @@ -116,11 +120,11 @@ extension UIAlertController { textField.autocapitalizationType = .words } - 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) @@ -148,7 +152,7 @@ extension UIAlertController { ]) alertController.setValue(attributedString, forKey: "attributedMessage") - } + }, for: .editingChanged) alertController.addAction(cancelAction) alertController.addAction(okAction) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 845630879e..780d4343eb 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -903,6 +903,7 @@ "_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."; From b5e2284c388f5e7e0440f28a46257ebda2f2a150 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 9 Nov 2026 09:27:36 +0100 Subject: [PATCH 27/42] fix: cancel share operations when the selected account is removed Clear pending uploads and authentication state when no account is available, and suppress upload UI updates after cancellation. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Upload.swift | 7 +++--- Share/Model/NCShareViewModel.swift | 29 +++++++++++++++++------ Share/View/NCShareView.swift | 1 - 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 68726e5443..44d4de0fb2 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -34,7 +34,7 @@ extension NCShareViewModel { } catch is CancellationError { // The extension was closed; do not present another screen. } catch { - if !isClosing { showAlert(description: error.localizedDescription) } + if !Task.isCancelled, !isClosing { showAlert(description: error.localizedDescription) } } } } @@ -63,7 +63,7 @@ extension NCShareViewModel { } catch is CancellationError { // No upload after cancellation or a destination change. } catch { - if !isClosing { showAlert(description: error.localizedDescription) } + if !Task.isCancelled, !isClosing { showAlert(description: error.localizedDescription) } } } } @@ -192,7 +192,7 @@ extension NCShareViewModel { var error: NKError? var errorContext: NCErrorContext = .generic - guard let window = presenter?.view.window else { + guard !Task.isCancelled, !isClosing, let window = presenter?.view.window else { return } let payload = LucidBannerPayload(stage: .button, @@ -216,6 +216,7 @@ extension NCShareViewModel { banner?.update(payload: payloadUpdate) error = await self.upload(metadata: metadata) + guard !Task.isCancelled, !isClosing else { return } if error != .success { errorContext = .upload(fileName: NCShareFilenameView.displayName(metadata.fileNameView)) break diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index ee2274280e..fb689be46d 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -285,12 +285,17 @@ final class NCShareViewModel: NSObject, ObservableObject { } private func validateAccount() -> Bool { + guard !isPresentingNoAccountAlert else { return false } guard !NCPasscodeModel.isPasscodeReset else { return showNoAccountAlert() } - if let account = sessionStore.account?.account, - NCManageDatabase.shared.getTableAccount(account: account) != nil { + 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 } @@ -303,12 +308,22 @@ final class NCShareViewModel: NSObject, ObservableObject { } private func showNoAccountAlert() -> Bool { + guard !isPresentingNoAccountAlert else { return false } + isPresentingNoAccountAlert = true + isAuthenticated = false + accountChangeID = UUID() + accountChangeTask?.cancel() + accountsTask?.cancel() + uploadTask?.cancel() + pendingUploadConfirmation = nil + uploadChoice = nil + uploadConfirmation = nil clearAccountView() - if !isPresentingNoAccountAlert { - isPresentingNoAccountAlert = true - showAlert(description: "_no_active_account_") { [weak self] in - self?.cancel(with: .noAccount) - } + banner?.dismiss() + if isPresentingPasscode { + cancel(with: .noAccount) + } else { + showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } } return false } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 286f0f9434..7b18b08fab 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -348,7 +348,6 @@ struct NCShareView: View { .foregroundStyle(.primary) Spacer(minLength: 0) Image(systemName: "ellipsis") - .cappedFont(.title2, maxDynamicType: .accessibility2) .foregroundStyle(Color.accentColor) .frame(width: 50, height: 50) .accessibilityHidden(true) From 874b3ff18b50baafa7f93a8ab6328a97eb865f19 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 9 Nov 2026 09:39:59 +0100 Subject: [PATCH 28/42] refactor: simplify share extension state management Derive account and folder readiness from existing state, keep upload data local, and silently remove unavailable recent folders. Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Accounts.swift | 22 ++---- Share/Model/NCShareViewModel+Folders.swift | 19 +++-- Share/Model/NCShareViewModel+Upload.swift | 77 ++++++++++----------- Share/Model/NCShareViewModel.swift | 50 ++++--------- Share/NCShareEntryController.swift | 1 - Share/View/NCShareAccountMenu.swift | 11 +-- Share/View/NCShareView.swift | 10 +-- 7 files changed, 78 insertions(+), 112 deletions(-) diff --git a/Share/Model/NCShareViewModel+Accounts.swift b/Share/Model/NCShareViewModel+Accounts.swift index b0e359562f..0096366a7f 100644 --- a/Share/Model/NCShareViewModel+Accounts.swift +++ b/Share/Model/NCShareViewModel+Accounts.swift @@ -9,9 +9,8 @@ extension NCShareViewModel { func loadAccounts() { guard isAuthenticated, !isClosing, !isLoading, !isPreparingUpload, !isLoadingAccounts else { return } - isLoadingAccounts = true accountsTask = Task { @MainActor [self] in - defer { isLoadingAccounts = false } + defer { accountsTask = nil } let selected = sessionStore.getSession().account let storedAccounts = await NCManageDatabase.shared.getAllAccountOrderAliasAsync() guard !Task.isCancelled, !isClosing, isAuthenticated, @@ -21,9 +20,6 @@ extension NCShareViewModel { showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } return } - accountAvatars = Dictionary(uniqueKeysWithValues: storedAccounts.map { account in - (account.account, utility.loadUserImage(for: account.user, displayName: account.displayName, urlBase: account.urlBase)) - }) accounts = storedAccounts.map { account in let name = account.displayName.isEmpty ? account.user : account.displayName return NCShareAccountItem( @@ -31,7 +27,8 @@ extension NCShareViewModel { name: account.alias.isEmpty ? name : name + " - " + account.alias, user: account.user, serverUrl: account.urlBase, - isSelected: account.account == selected) + isSelected: account.account == selected, + avatar: utility.loadUserImage(for: account.user, displayName: account.displayName, urlBase: account.urlBase)) }.sorted { $0.isSelected && !$1.isSelected } } } @@ -54,7 +51,6 @@ extension NCShareViewModel { accountChangeID = changeID isSwitchingAccount = true clearAccountView(preservingContent: true) - serverUrl = "" accountChangeTask = Task { @MainActor [self] in defer { if accountChangeID == changeID { isSwitchingAccount = false } } let selectedSession = await sessionStore.setSessionAccount(account) @@ -78,18 +74,14 @@ extension NCShareViewModel { autoUploadFileName = NCManageDatabase.shared.getAccountAutoUploadFileName(account: account) autoUploadDirectory = NCManageDatabase.shared.getAccountAutoUploadDirectory( account: account, urlBase: session.urlBase, userId: session.userId) - serverUrl = utilityFileSystem.getHomeServer(session: session) - let name = tblAccount.displayName.isEmpty ? tblAccount.user : tblAccount.displayName - accountName = tblAccount.alias.isEmpty ? name : name + " - " + tblAccount.alias // Publish the local browser before capabilities can trigger a network request. await initializeFolderBrowser(refreshFromServer: false) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } - isAvailable = true isSwitchingAccount = false loadAccounts() await loadServerCapabilities(account: account, changeID: changeID) guard !Task.isCancelled, !isClosing, accountChangeID == changeID else { return } - if serverCapabilities != nil, let root = folders.first { + if serverCapabilities != nil, let root = rootFolder { requestFolderChildren(root, refresh: true) } } @@ -103,10 +95,10 @@ extension NCShareViewModel { capabilitiesTask = Task { @MainActor in await loadServerCapabilities(account: account, changeID: changeID) guard isCurrentCapabilitiesRequest(account: account, changeID: changeID), serverCapabilities != nil else { return } - if folders.isEmpty { + if let rootFolder { + requestFolderChildren(rootFolder, refresh: true) + } else { await initializeFolderBrowser() - } else if let root = folders.first { - requestFolderChildren(root, refresh: true) } } } diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index 11ecfad137..f9550ab981 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -17,7 +17,7 @@ extension NCShareViewModel { for child in folderChildren[folder.serverUrl] ?? [] { append(child, depth: depth + 1) } } } - for folder in folders { append(folder, depth: 0) } + if let rootFolder { append(rootFolder, depth: 0) } return rows } @@ -30,7 +30,7 @@ extension NCShareViewModel { folderRequests.removeAll() // Keep the previous snapshot visible but disabled while the account changes. if !preservingContent { - folders = [] + rootFolder = nil recentFolders = [] selectedFolder = nil isRecentFolderSelected = false @@ -38,7 +38,6 @@ extension NCShareViewModel { expandedFolderURLs = [] } folderScrollTarget = nil - loadingFolderURLs = [] loadedFolderURLs = [] folderLoadErrors = [:] isLoading = false @@ -63,7 +62,7 @@ extension NCShareViewModel { let cached = await cachedFolderChildren(at: home, parent: nil, session: session) guard isCurrentBrowser(browserID, account: session.account) else { return } // Publish the replacement snapshot together, without an empty intermediate list. - folders = [root] + rootFolder = root selectedFolder = root isRecentFolderSelected = false expandedFolderURLs = [home] @@ -92,11 +91,10 @@ extension NCShareViewModel { /// 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, !loadingFolderURLs.contains(folder.serverUrl), + 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. - loadingFolderURLs.insert(folder.serverUrl) folderTasks[folder.serverUrl] = Task { @MainActor in await loadFolderChildren(folder, browserID: browserID) } @@ -120,6 +118,7 @@ extension NCShareViewModel { 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 @@ -133,7 +132,6 @@ extension NCShareViewModel { showAlert(description: error.errorDescription) return } - serverUrl = folder.serverUrl selectedFolder = selected isRecentFolderSelected = fromRecents expandedFolderURLs.insert(selected.serverUrl) @@ -166,7 +164,6 @@ extension NCShareViewModel { folderLoadErrors[folder.serverUrl] = nil defer { if folderBrowserID == browserID { - loadingFolderURLs.remove(folder.serverUrl) folderRequests[folder.serverUrl] = nil folderTasks[folder.serverUrl] = nil } @@ -203,7 +200,7 @@ extension NCShareViewModel { folderLoadErrors[folder.serverUrl] = NSLocalizedString("_share_folder_unavailable_", comment: "") return } - let layout = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, serverUrl: folder.serverUrl) + 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 } let ordered = sorted.filter(\.favorite) + sorted.filter { !$0.favorite } @@ -214,14 +211,14 @@ extension NCShareViewModel { loadedFolderURLs.insert(folder.serverUrl) let metadata = listing.metadataFolder let updated = folderSnapshot(metadata, url: folder.serverUrl, parent: nil, session: session) - if folders.first?.serverUrl == folder.serverUrl { folders = [updated] } + if rootFolder?.serverUrl == folder.serverUrl { rootFolder = updated } if selectedFolder?.serverUrl == folder.serverUrl { selectedFolder = updated } } private func cachedFolderChildren(at url: String, parent: tableMetadata?, session: NCSession.Session) async -> [NCShareFolderItem] { - let layout = NCManageDatabase.shared.getLayoutForView(account: session.account, key: keyLayout, serverUrl: url) + 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) diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 44d4de0fb2..66551a219a 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -73,7 +73,7 @@ extension NCShareViewModel { /// when the existing resource is a file with a usable ETag. private func prepareUploadPlan(capabilities: NKCapabilities.Capabilities) async throws -> NCShareUploadConfirmation { let session = sessionStore.getSession() - let destination = serverUrl + guard let destination = selectedFolder?.serverUrl else { throw CancellationError() } let sourceNames = fileNames guard !sourceNames.isEmpty else { throw NCShareRequestError.noFiles } let renamer = FileAutoRenamer(capabilities: capabilities) @@ -93,7 +93,7 @@ extension NCShareViewModel { 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, serverUrl == destination else { throw CancellationError() } + 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 { @@ -143,16 +143,19 @@ extension NCShareViewModel { try Task.checkCancellation() guard !isOffline else { throw URLError(.notConnectedToInternet) } let session = sessionStore.getSession() - guard !isClosing, isAuthenticated, plan.account == session.account, plan.serverUrl == serverUrl else { throw CancellationError() } - uploadMetadata.removeAll() - replacementETags.removeAll() + 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) } - uploadMetadata.removeAll() - replacementETags.removeAll() } } for item in plan.items { @@ -167,7 +170,7 @@ extension NCShareViewModel { 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: sceneIdentifier) + fileName: fileName, ocId: ocId, serverUrl: plan.serverUrl, session: session, sceneIdentifier: nil) metadata.nativeFormat = true metadata.session = NCNetworking.shared.sessionUpload metadata.sessionSelector = NCGlobal.shared.selectorUploadFileShareExtension @@ -175,17 +178,14 @@ extension NCShareViewModel { metadata.status = NCGlobal.shared.metadataStatusWaitUpload metadata.sessionDate = Date() uploadMetadata.append(metadata) - if replacing, let etag = plan.replacementETag { - replacementETags[ocId] = etag.hasPrefix("\"") || etag.hasPrefix("W/") ? etag : "\"" + etag + "\"" - } } try Task.checkCancellation() - guard !isClosing, isAuthenticated, sessionStore.getSession().account == plan.account, serverUrl == plan.serverUrl else { throw CancellationError() } + guard !isClosing, isAuthenticated, sessionStore.getSession().account == plan.account, selectedFolder?.serverUrl == plan.serverUrl else { throw CancellationError() } staged = true - await uploadAndExit() + await uploadAndExit(uploadMetadata, replacementETag: replacementETag) } - private func uploadAndExit() async { + 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() @@ -198,24 +198,23 @@ extension NCShareViewModel { let payload = LucidBannerPayload(stage: .button, vPosition: .center, blocksTouches: true) - (banner, token) = showUploadBanner(windowScene: window.windowScene, - payload: payload, - allowMinimizeOnTap: false, - onButtonTap: { - self.cancel() - }) + 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: "") + " \(self.fileNames.count)", + 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) + 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)) @@ -230,12 +229,12 @@ extension NCShareViewModel { 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: self.token) + 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: self.token) + banner?.update(payload: payload, for: token) } guard !Task.isCancelled, !isClosing else { return } @@ -249,7 +248,7 @@ extension NCShareViewModel { cancel() } - private func upload(metadata: tableMetadata) async -> NKError { + 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) @@ -262,12 +261,8 @@ extension NCShareViewModel { 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 - } + let chunkSize = NCGlobal.shared.chunkSize + metadata.chunk = metadata.size > chunkSize ? chunkSize : 0 // E2EE metadata.e2eEncrypted = metadata.isDirectoryE2EE @@ -277,20 +272,20 @@ extension NCShareViewModel { controller: presenter, banner: banner, stageBanner: nil, - tokenBanner: self.token) + tokenBanner: token) } else if metadata.chunk > 0 { banner?.update(payload: LucidBannerPayload.Update(systemImage: "gearshape.arrow.triangle.2.circlepath", imageAnimation: .rotate), - for: self.token) + 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: self.replacementETags[metadata.ocId] != nil, - ifMatch: self.replacementETags[metadata.ocId]) { total, counter in + overwrite: replacementETag != nil, + ifMatch: replacementETag) { total, counter in Task {@MainActor in - self.banner?.update(payload: LucidBannerPayload.Update(progress: Double(counter) / Double(total)), for: self.token) + self.banner?.update(payload: LucidBannerPayload.Update(progress: Double(counter) / Double(total)), for: token) } } uploadStart: { _ in Task {@MainActor in @@ -298,11 +293,11 @@ extension NCShareViewModel { systemImage: "arrowshape.up.circle", imageAnimation: .breathe ) - self.banner?.update(payload: payload, for: self.token) + self.banner?.update(payload: payload, for: token) } } uploadProgressHandler: { _, _, progress in Task {@MainActor in - self.banner?.update(payload: LucidBannerPayload.Update(progress: progress), for: self.token) + self.banner?.update(payload: LucidBannerPayload.Update(progress: progress), for: token) } } assembling: { Task {@MainActor in @@ -310,7 +305,7 @@ extension NCShareViewModel { systemImage: "gearshape.arrow.triangle.2.circlepath", imageAnimation: .rotate ) - self.banner?.update(payload: payload, for: self.token) + self.banner?.update(payload: payload, for: token) } } return results.error @@ -327,11 +322,11 @@ extension NCShareViewModel { serverUrlFileName: metadata.serverUrlFileName, creationDate: metadata.creationDate as Date, dateModificationFile: metadata.date as Date, - customHeaders: replacementETags[metadata.ocId].map { ["If-Match": $0] } ?? ["If-None-Match": "*"], + customHeaders: replacementETag.map { ["If-Match": $0] } ?? ["If-None-Match": "*"], progressHandler: { _, _, fractionCompleted in Task {@MainActor in self.banner?.update(payload: LucidBannerPayload.Update(progress: fractionCompleted), - for: self.token) + for: token) } }) return results.error diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index fb689be46d..dfe2e2b04b 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -13,23 +13,21 @@ import UniformTypeIdentifiers /// in the corresponding extensions; input files remain local until upload staging. @MainActor final class NCShareViewModel: NSObject, ObservableObject { - @Published var folders: [NCShareFolderItem] = [] + @Published var rootFolder: NCShareFolderItem? @Published var recentFolders: [NCShareFolderItem] = [] @Published var selectedFolder: NCShareFolderItem? @Published var isRecentFolderSelected = false @Published var folderScrollTarget: String? @Published var folderChildren: [String: [NCShareFolderItem]] = [:] @Published var expandedFolderURLs: Set = [] - @Published var loadingFolderURLs: Set = [] @Published var loadedFolderURLs: Set = [] @Published var folderLoadErrors: [String: String] = [:] - var folderTasks: [String: Task] = [:] + @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) + 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] = [:] @@ -37,25 +35,18 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var isLoading = false @Published private(set) var isOffline = false private var connectionMonitor: NWPathMonitor? - // Local account/browser state is ready. Authentication and upload readiness - // are separate: cached folders may be displayed before capabilities arrive. - @Published var isAvailable = false @Published var isPreparingUpload = false @Published var uploadConfirmation: NCShareUploadConfirmation? // Keep the plan after SwiftUI clears the sheet binding, until onDismiss runs. var pendingUploadConfirmation: NCShareUploadConfirmation? var uploadChoice: NCShareUploadChoice? var uploadTask: Task? - var replacementETags: [String: String] = [:] - var isClosing = false + private(set) var isClosing = false @Published var isPreparingInput = false @Published var inputErrorMessage = "" @Published var alertMessage = "" private var alertDismissAction: (() -> Void)? - @Published var accountName = "" @Published var accounts: [NCShareAccountItem] = [] - @Published var accountAvatars: [String: UIImage] = [:] - @Published var isLoadingAccounts = false @Published var isSwitchingAccount = false @Published var serverCapabilities: NKCapabilities.Capabilities? @Published var isLoadingCapabilities = false @@ -64,38 +55,32 @@ final class NCShareViewModel: NSObject, ObservableObject { var isUploadDestinationReady: Bool { guard let selectedFolder else { return false } - return isAccountReady && alertMessage.isEmpty && !loadingFolderURLs.contains(selectedFolder.serverUrl) && + return isAccountReady && alertMessage.isEmpty && folderTasks[selectedFolder.serverUrl] == nil && folderLoadErrors[selectedFolder.serverUrl] == nil } var capabilitiesTask: Task? var capabilitiesRequest: URLSessionTask? - var accountsTask: Task? + @Published var accountsTask: Task? + var isLoadingAccounts: Bool { accountsTask != nil } var accountChangeTask: Task? var accountChangeID = UUID() @Published private(set) var isAuthenticated = false weak var presenter: UIViewController? - var requestContext: NSExtensionContext? + private var requestContext: NSExtensionContext? lazy var sessionStore = NCShareSessionStore() - var serverUrl = "" let inputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("NextcloudShare-" + UUID().uuidString, isDirectory: true) private var inputTask: Task? - private var hasStarted = false private var foregroundObserver: NSObjectProtocol? private var backgroundObserver: NSObjectProtocol? - let keyLayout = NCGlobal.shared.layoutViewShareExtension var autoUploadFileName = "" var autoUploadDirectory = "" - var uploadMetadata: [tableMetadata] = [] let utilityFileSystem = NCUtilityFileSystem() let utility = NCUtility() - let global = NCGlobal.shared - var maintenanceMode: Bool = false - var token: Int? + private var maintenanceMode = false var banner: LucidBanner? - var sceneIdentifier: String = UUID().uuidString private var isPresentingNoAccountAlert = false private var isPresentingPasscode = false private var isPasscodeAuthenticated = false @@ -103,8 +88,7 @@ final class NCShareViewModel: NSObject, ObservableObject { /// 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 !hasStarted else { return } - hasStarted = true + guard connectionMonitor == nil else { return } let monitor = NWPathMonitor() connectionMonitor = monitor // A usable network path does not guarantee that the server is reachable. @@ -336,8 +320,7 @@ final class NCShareViewModel: NSObject, ObservableObject { capabilitiesErrorMessage = "" resetFolderBrowser(preservingContent: preservingContent) if !preservingContent { - isAvailable = false - accountName = "" + accounts = [] } } @@ -408,7 +391,7 @@ final class NCShareViewModel: NSObject, ObservableObject { 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.folders.first else { return } + guard var branch = self.rootFolder else { return } while true { guard !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID else { return } self.expandedFolderURLs.insert(branch.serverUrl) @@ -427,7 +410,6 @@ final class NCShareViewModel: NSObject, ObservableObject { guard let created = self.folderChildren[folder.serverUrl]?.first(where: { $0.name == name }), self.isAuthenticated else { return } self.selectedFolder = created - self.serverUrl = created.serverUrl self.isRecentFolderSelected = false self.expandedFolderURLs.insert(created.serverUrl) self.requestFolderChildren(created) @@ -553,12 +535,13 @@ enum NCShareUploadChoice: Equatable, Sendable { } /// Account picker data contains no live Realm objects or mutable account flags. -struct NCShareAccountItem: Identifiable, Sendable { +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 @@ -567,18 +550,15 @@ extension NCShareViewModel { /// Session storage stays lazy so constructing this model does not read accounts. static func preview() -> NCShareViewModel { let model = NCShareViewModel() - model.isAvailable = true model.serverCapabilities = NKCapabilities.Capabilities() model.isAuthenticated = true - model.accountName = "Personal" 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.serverUrl = documents.serverUrl model.selectedFolder = documents - model.folders = [root] + model.rootFolder = root model.folderChildren = [home: [documents, photos], documents.serverUrl: [work]] model.expandedFolderURLs = [home, documents.serverUrl] model.loadedFolderURLs = [home, documents.serverUrl] diff --git a/Share/NCShareEntryController.swift b/Share/NCShareEntryController.swift index 029d947a3d..68072b6ec8 100644 --- a/Share/NCShareEntryController.swift +++ b/Share/NCShareEntryController.swift @@ -35,7 +35,6 @@ final class NCShareEntryController: UIHostingController { // Returning from a presented sheet must not restart the share request. guard !hasAppeared, !model.isClosing else { return } hasAppeared = true - NCNetworking.shared.setupScene(sceneIdentifier: model.sceneIdentifier, controller: self) if let windowScene = view.window?.windowScene { model.banner = LucidBannerRegistry.shared.banner(for: windowScene) } diff --git a/Share/View/NCShareAccountMenu.swift b/Share/View/NCShareAccountMenu.swift index 18585408d2..d701bb60fe 100644 --- a/Share/View/NCShareAccountMenu.swift +++ b/Share/View/NCShareAccountMenu.swift @@ -9,6 +9,7 @@ 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 ?? "" }, @@ -19,18 +20,18 @@ struct NCShareAccountMenu: View { Text(verbatim: account.name) Text(verbatim: account.user + " · " + account.serverUrl) } icon: { - avatar(for: account.id) + avatar(account.avatar) } .tag(account.id) } } .pickerStyle(.inline) } label: { - avatar(for: model.accounts.first(where: \.isSelected)?.id ?? "") + avatar(selected?.avatar) .frame(minWidth: 44, minHeight: 44) } .accessibilityLabel(NSLocalizedString("_account_select_", comment: "")) - .accessibilityValue(Text(verbatim: model.accountName)) + .accessibilityValue(Text(verbatim: selected?.name ?? "")) .buttonStyle(.plain) .dynamicTypeSize(.xSmall ... .accessibility2) .disabled(model.accounts.count < 2) @@ -38,8 +39,8 @@ struct NCShareAccountMenu: View { } @ViewBuilder - private func avatar(for account: String) -> some View { - if let image = model.accountAvatars[account] { + private func avatar(_ image: UIImage?) -> some View { + if let image { Image(uiImage: image) .renderingMode(.original) .resizable() diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 7b18b08fab..d04b038942 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -11,13 +11,13 @@ struct NCShareView: View { private var isRefreshing: Bool { model.isLoading || model.isSwitchingAccount || model.isLoadingAccounts || - model.isLoadingCapabilities || !model.loadingFolderURLs.isEmpty + model.isLoadingCapabilities || !model.folderTasks.isEmpty } var body: some View { NavigationStack { Group { - if model.isAvailable && model.isAuthenticated { + if model.rootFolder != nil && model.isAuthenticated { VStack(spacing: 0) { statusView .frame(maxWidth: .infinity) @@ -85,7 +85,7 @@ struct NCShareView: View { .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { - if model.isAvailable && model.isAuthenticated { + if model.rootFolder != nil && model.isAuthenticated { HStack(spacing: 8) { NCShareAccountMenu(model: model) .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) @@ -423,7 +423,9 @@ struct NCShareView: View { let model = NCShareViewModel.preview() model.folderChildren = [:] model.recentFolders = [] - model.loadedFolderURLs = Set(model.folders.map(\.serverUrl)) + if let rootFolder = model.rootFolder { + model.loadedFolderURLs = [rootFolder.serverUrl] + } return NCShareView(model: model) .environment(\.locale, Locale(identifier: "en_US")) .allowsHitTesting(false) From c481e188c1f3660adf336dda8c31e273f9f4da77 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 9 Nov 2026 10:25:59 +0100 Subject: [PATCH 29/42] fix: ensure shared UTF-8 text uses a txt extension Strengthen rich-text sharing coverage to verify the original RTF data is preserved. Signed-off-by: Marino Faggiana --- Share/NCShareLoaderItemProvider.swift | 3 ++- .../NCShareLoaderItemProviderTests.swift | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift index bbca5be0a5..63b15fd5aa 100644 --- a/Share/NCShareLoaderItemProvider.swift +++ b/Share/NCShareLoaderItemProvider.swift @@ -382,7 +382,8 @@ final class NCShareLoaderItemProvider { .replacingOccurrences(of: "\\", with: "_") .replacingOccurrences(of: "/", with: "_") if name.isEmpty || name == "." || name == ".." { name = "Shared item" } - if let type, let preferredExtension = type.preferredFilenameExtension { + // 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 diff --git a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift index e1717198db..aea5dfcd33 100644 --- a/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift +++ b/Tests/NextcloudUnitTests/NCShareLoaderItemProviderTests.swift @@ -102,8 +102,10 @@ struct NCShareLoaderItemProviderTests { 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 item = NSItemProvider(object: attributed) - item.suggestedName = "Note" + 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) @@ -111,12 +113,8 @@ struct NCShareLoaderItemProviderTests { #expect(output.fileNames.count == 1) let name = try #require(output.fileNames.first) let file = directory.appendingPathComponent(name) - if file.pathExtension == "txt" { - #expect(try String(contentsOf: file, encoding: .utf8) == text) - } else { - let restored = try NSAttributedString(url: file, options: [:], documentAttributes: nil) - #expect(restored.string == text) - } + #expect(file.pathExtension == "rtf") + #expect(try Data(contentsOf: file) == rtf) } @Test("An empty file is valid input") From c2492fdc1f695e68cca8d7d4c3ebe4cf2a14fe6a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 9 Nov 2026 11:40:42 +0100 Subject: [PATCH 30/42] refactor: improve share upload conflict layout and actions Keep conflict actions visible below the file list, clarify button labels, and reuse a shared upload button. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 7 ++ Share/View/NCShareUploadButton.swift | 34 ++++++++++ Share/View/NCShareUploadConflictView.swift | 68 ++++++++++++++----- Share/View/NCShareView.swift | 25 ++----- .../en.lproj/Localizable.strings | 5 +- 5 files changed, 99 insertions(+), 40 deletions(-) create mode 100644 Share/View/NCShareUploadButton.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 81e1b6e7ef..571f9c4602 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -84,6 +84,7 @@ 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 */; }; + AB1109260000000000010101 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; A71107260000000000000101 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.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 */; }; @@ -96,6 +97,7 @@ 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 */; }; + AB1109260000000000010102 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; A71107260000000000000102 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.swift */; }; AB1028260000000000080008 /* NCShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000020009 /* NCShareView.swift */; }; AB1028260000000000090100 /* NCShareRecentFoldersStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1028260000000000090000 /* NCShareRecentFoldersStore.swift */; }; @@ -1406,6 +1408,7 @@ 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 = ""; }; + AB1109260000000000010001 /* NCShareUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadButton.swift; sourceTree = ""; }; A71107260000000000000100 /* NCShareFilenameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareFilenameView.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 = ""; }; @@ -2365,6 +2368,7 @@ AB1028260000000000020009 /* NCShareView.swift */, AB1028260000000000060000 /* NCShareAccountMenu.swift */, AB1028260000000000050001 /* NCShareUploadConflictView.swift */, + AB1109260000000000010001 /* NCShareUploadButton.swift */, A71107260000000000000100 /* NCShareFilenameView.swift */, ); path = View; @@ -4909,6 +4913,7 @@ 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 */, @@ -5051,6 +5056,7 @@ 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 */, @@ -6457,6 +6463,7 @@ NCShareSessionStore.swift, NCShareAccountMenu.swift, NCShareUploadConflictView.swift, + NCShareUploadButton.swift, NCShareFilenameView.swift, NCShareView.swift, ); 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 index ff52326329..118f3437d7 100644 --- a/Share/View/NCShareUploadConflictView.swift +++ b/Share/View/NCShareUploadConflictView.swift @@ -10,36 +10,70 @@ struct NCShareUploadConflictView: View { let resolve: @MainActor (NCShareUploadChoice?) -> Void var body: some View { + let renamedItems = confirmation.renamedItems NavigationStack { - List { - Section { + 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) } - Section(NSLocalizedString("_share_conflict_new_names_", comment: "")) { - ForEach(confirmation.renamedItems, id: \.sourceName) { item in - VStack(alignment: .leading, spacing: 4) { - NCShareFilenameView(name: item.sourceName) - .cappedFont(.caption, maxDynamicType: .accessibility2) - .foregroundStyle(.secondary) - NCShareFilenameView(name: item.fileName) - } - } - } - Section { + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 16) { if !confirmation.isBatch, confirmation.replacementFileName != nil { - Button(NSLocalizedString("_share_conflict_replace_", comment: ""), role: .destructive) { + 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) } - Button(confirmation.isBatch - ? NSLocalizedString("_upload_", comment: "") - : NSLocalizedString("_share_conflict_keep_both_", comment: "")) { + + 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 { diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index d04b038942..9ab6fcf2fa 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -387,27 +387,10 @@ struct NCShareView: View { } private var uploadButton: some View { - Button(action: model.upload) { - HStack(spacing: 8) { - if model.isPreparingUpload { - ProgressView() - .tint(.white) - Text(NSLocalizedString("_share_conflict_checking_", comment: "")) - } else { - Text(String.localizedStringWithFormat(NSLocalizedString("_share_upload_to_", comment: ""), model.fileNames.count)) - } - } - .lineLimit(2) - .truncationMode(.middle) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 12)) - .padding(.horizontal, 16) - .disabled(isRefreshing || model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || - model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || - model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + NCShareUploadButton(fileCount: model.fileNames.count, isPreparing: model.isPreparingUpload, action: model.upload) + .disabled(isRefreshing || model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || + model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || + model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 01ef7392b6..d25674f40b 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -887,10 +887,11 @@ /* Share extension upload conflicts */ "_share_conflict_title_" = "Files already exist"; -"_share_conflict_single_message_" = "An item named \"%@\" already exists in this folder. You can keep both files, or replace the existing file when available."; +"_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"; +"_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."; From fc78c2a549775df490472ae3cf6372a32913857d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 9 Nov 2026 11:44:35 +0100 Subject: [PATCH 31/42] fix: left-align capability errors in share extension Signed-off-by: Marino Faggiana --- Share/View/NCShareView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 9ab6fcf2fa..05b836e7bf 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -124,7 +124,8 @@ struct NCShareView: View { VStack(spacing: 8) { Text(verbatim: model.capabilitiesErrorMessage) .cappedFont(.subheadline, maxDynamicType: .accessibility2) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) Button(NSLocalizedString("_share_capabilities_retry_", comment: ""), action: model.retryCapabilities) .disabled(model.isSwitchingAccount) } From 1f657b2c2277fe0886f69d0dd7371ca0f6d3986a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 07:50:07 +0100 Subject: [PATCH 32/42] fix: improve Share extension passcode and biometric authentication Present the passcode within the Share panel and start biometrics after presentation completes. Add the Face ID usage description and resume authentication after upload confirmation is dismissed. Signed-off-by: Marino Faggiana --- Brand/Share.plist | 4 +- Share/Model/NCShareViewModel+Upload.swift | 3 + Share/Model/NCShareViewModel.swift | 6 +- .../Utility/Passcode/NCPasscodeModel.swift | 9 ++- .../Utility/Passcode/NCPasscodeView.swift | 66 +++++++++---------- 5 files changed, 46 insertions(+), 42 deletions(-) diff --git a/Brand/Share.plist b/Brand/Share.plist index f5512c8a59..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 @@ -38,4 +40,4 @@ com.apple.share-services - \ No newline at end of file + diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 66551a219a..1a9d17ef4e 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -54,6 +54,9 @@ extension NCShareViewModel { uploadChoice = nil guard let choice, let capabilities = serverCapabilities, isAuthenticated, !isClosing else { isPreparingUpload = false + if !isAuthenticated && !isClosing { + validateAccountAndRequestPasscode() + } return } uploadTask = Task { @MainActor in diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index dfe2e2b04b..72fd70b9f0 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -469,11 +469,11 @@ extension NCShareViewModel { return } isAuthenticated = false - guard !isPresentingPasscode, + // The sheet's onDismiss resumes the passcode flow after its transition. + guard !isPresentingPasscode, pendingUploadConfirmation == nil, let presenter else { return } isPresentingPasscode = true - // Embed in the content controller; the helper hides the outer navigation bar. - NCPasscodeView.embed(in: presenter) { [weak self] result in + NCPasscodeView.present(from: presenter, allowCancel: true) { [weak self] result in guard let self else { return } self.isPresentingPasscode = false switch result { 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() From bbf0302ddbcd81e6632aac74fb99c84e1eba0455 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 08:10:32 +0100 Subject: [PATCH 33/42] refactor: simplify Share extension state and upload flow Signed-off-by: Marino Faggiana --- Share/Model/NCShareViewModel+Folders.swift | 16 +-- Share/Model/NCShareViewModel+Upload.swift | 52 +++++---- Share/Model/NCShareViewModel.swift | 120 +++++++++------------ Share/NCShareLoaderItemProvider.swift | 73 +++++-------- Share/View/NCShareView.swift | 99 ++++++++--------- 5 files changed, 149 insertions(+), 211 deletions(-) diff --git a/Share/Model/NCShareViewModel+Folders.swift b/Share/Model/NCShareViewModel+Folders.swift index f9550ab981..bd826dd9a3 100644 --- a/Share/Model/NCShareViewModel+Folders.swift +++ b/Share/Model/NCShareViewModel+Folders.swift @@ -59,7 +59,7 @@ extension NCShareViewModel { image: item.isEncrypted ? NCImageCache.shared.getFolderEncrypted(account: session.account) : NCImageCache.shared.getFolder(account: session.account), isEncrypted: item.isEncrypted) } - let cached = await cachedFolderChildren(at: home, parent: nil, session: session) + 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 @@ -154,7 +154,7 @@ extension NCShareViewModel { return true } - private func isCurrentBrowser(_ browserID: UUID, account: String) -> Bool { + func isCurrentBrowser(_ browserID: UUID, account: String) -> Bool { !Task.isCancelled && !isClosing && folderBrowserID == browserID && sessionStore.getSession().account == account } @@ -203,11 +203,7 @@ extension NCShareViewModel { 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 } - let ordered = sorted.filter(\.favorite) + sorted.filter { !$0.favorite } - folderChildren[folder.serverUrl] = ordered.map { metadata in - let url = utilityFileSystem.createServerUrl(serverUrl: metadata.serverUrl, fileName: metadata.fileName) - return folderSnapshot(metadata, url: url, parent: listing.metadataFolder, session: session) - } + 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) @@ -217,13 +213,17 @@ extension NCShareViewModel { } } - private func cachedFolderChildren(at url: String, parent: tableMetadata?, session: NCSession.Session) async -> [NCShareFolderItem] { + 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) diff --git a/Share/Model/NCShareViewModel+Upload.swift b/Share/Model/NCShareViewModel+Upload.swift index 1a9d17ef4e..77d4528467 100644 --- a/Share/Model/NCShareViewModel+Upload.swift +++ b/Share/Model/NCShareViewModel+Upload.swift @@ -12,29 +12,21 @@ 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 isUploadDestinationReady, isAuthenticated, !isPreparingInput, inputErrorMessage.isEmpty, !isPreparingUpload, - !isSwitchingAccount, !isLoadingAccounts, !isLoading, !fileNames.isEmpty else { return } - guard let capabilities = serverCapabilities else { return } - isPreparingUpload = true - uploadTask = Task { @MainActor in - defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } - do { - let plan = try await prepareUploadPlan(capabilities: capabilities) - try Task.checkCancellation() - guard !isClosing, isAuthenticated else { return } - if plan.requiresConfirmation { - pendingUploadConfirmation = plan - uploadConfirmation = plan - } else { - try await stageAndUpload(plan: plan, replacing: false, capabilities: capabilities) - } - } catch is CancellationError { - // The extension was closed; do not present another screen. - } catch { - if !Task.isCancelled, !isClosing { showAlert(description: error.localizedDescription) } + 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) } } } @@ -42,16 +34,13 @@ extension NCShareViewModel { // 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?) { - guard pendingUploadConfirmation != nil else { return } - uploadChoice = choice - uploadConfirmation = nil + pendingUploadConfirmation?.choice = choice + isShowingUploadConfirmation = false } func uploadConfirmationDismissed() { - guard let plan = pendingUploadConfirmation else { return } - let choice = uploadChoice + guard let (plan, choice) = pendingUploadConfirmation else { return } pendingUploadConfirmation = nil - uploadChoice = nil guard let choice, let capabilities = serverCapabilities, isAuthenticated, !isClosing else { isPreparingUpload = false if !isAuthenticated && !isClosing { @@ -59,10 +48,17 @@ extension NCShareViewModel { } 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 { isPreparingUpload = false } + defer { if pendingUploadConfirmation == nil { isPreparingUpload = false } } do { - try await stageAndUpload(plan: plan, replacing: choice == .replace, capabilities: capabilities) + try await operation() } catch is CancellationError { // No upload after cancellation or a destination change. } catch { diff --git a/Share/Model/NCShareViewModel.swift b/Share/Model/NCShareViewModel.swift index 72fd70b9f0..d03cb32539 100644 --- a/Share/Model/NCShareViewModel.swift +++ b/Share/Model/NCShareViewModel.swift @@ -13,6 +13,10 @@ import UniformTypeIdentifiers /// in the corresponding extensions; input files remain local until upload staging. @MainActor final class NCShareViewModel: NSObject, ObservableObject { + private enum Access { + case locked, presentingPasscode, unprotected, unlocked, accountUnavailable + } + @Published var rootFolder: NCShareFolderItem? @Published var recentFolders: [NCShareFolderItem] = [] @Published var selectedFolder: NCShareFolderItem? @@ -36,10 +40,9 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published private(set) var isOffline = false private var connectionMonitor: NWPathMonitor? @Published var isPreparingUpload = false - @Published var uploadConfirmation: NCShareUploadConfirmation? - // Keep the plan after SwiftUI clears the sheet binding, until onDismiss runs. - var pendingUploadConfirmation: NCShareUploadConfirmation? - var uploadChoice: NCShareUploadChoice? + @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 @@ -53,10 +56,11 @@ final class NCShareViewModel: NSObject, ObservableObject { @Published var capabilitiesErrorMessage = "" var isAccountReady: Bool { serverCapabilities != nil && !isSwitchingAccount && !isOffline } - var isUploadDestinationReady: Bool { + var canUseUploadDestination: Bool { guard let selectedFolder else { return false } - return isAccountReady && alertMessage.isEmpty && folderTasks[selectedFolder.serverUrl] == nil && - folderLoadErrors[selectedFolder.serverUrl] == nil + return canUseFolderBrowser && !isLoadingCapabilities && folderTasks.isEmpty && + folderLoadErrors[selectedFolder.serverUrl] == nil && alertMessage.isEmpty && + !isPreparingInput && inputErrorMessage.isEmpty } var capabilitiesTask: Task? @@ -65,7 +69,8 @@ final class NCShareViewModel: NSObject, ObservableObject { var isLoadingAccounts: Bool { accountsTask != nil } var accountChangeTask: Task? var accountChangeID = UUID() - @Published private(set) var isAuthenticated = false + @Published private var access = Access.locked + var isAuthenticated: Bool { access == .unprotected || access == .unlocked } weak var presenter: UIViewController? private var requestContext: NSExtensionContext? @@ -81,9 +86,6 @@ final class NCShareViewModel: NSObject, ObservableObject { let utility = NCUtility() private var maintenanceMode = false var banner: LucidBanner? - private var isPresentingNoAccountAlert = false - private var isPresentingPasscode = false - private var isPasscodeAuthenticated = false /// Starts account setup and attachment import independently, once per request. /// Protected content remains hidden until passcode authentication succeeds. @@ -128,12 +130,12 @@ final class NCShareViewModel: NSObject, ObservableObject { self.accountsTask?.cancel() guard !self.maintenanceMode, self.validateAccount() else { return } self.resolveUploadConfirmation(nil) - self.isPasscodeAuthenticated = false + if self.access == .unlocked { self.access = .locked } self.requestPasscodeIfNeeded() } } // Verify version - let versionApp = NCUtility().getVersionMaintenance() + let versionApp = utility.getVersionMaintenance() if let groupDefaults = UserDefaults(suiteName: NCBrandOptions.shared.capabilitiesGroup) { let lastVersion = groupDefaults.string(forKey: NCGlobal.shared.udLastVersion) if lastVersion != versionApp { @@ -143,7 +145,7 @@ final class NCShareViewModel: NSObject, ObservableObject { } if let account = sessionStore.account?.account { - isAuthenticated = !NCPreferences().presentPasscode + access = NCPreferences().presentPasscode ? .locked : .unprotected changeAccount(account) } guard let items = requestContext?.inputItems as? [NSExtensionItem] else { @@ -252,6 +254,12 @@ final class NCShareViewModel: NSObject, ObservableObject { } 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() @@ -264,12 +272,10 @@ final class NCShareViewModel: NSObject, ObservableObject { for task in folderTasks.values { task.cancel() } for request in folderRequests.values { request.cancel() } if !isPreparingInput { try? FileManager.default.removeItem(at: inputDirectory) } - if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) } - if let backgroundObserver { NotificationCenter.default.removeObserver(backgroundObserver) } } private func validateAccount() -> Bool { - guard !isPresentingNoAccountAlert else { return false } + guard access != .accountUnavailable else { return false } guard !NCPasscodeModel.isPasscodeReset else { return showNoAccountAlert() } @@ -292,19 +298,17 @@ final class NCShareViewModel: NSObject, ObservableObject { } private func showNoAccountAlert() -> Bool { - guard !isPresentingNoAccountAlert else { return false } - isPresentingNoAccountAlert = true - isAuthenticated = false + let wasPresentingPasscode = access == .presentingPasscode + access = .accountUnavailable accountChangeID = UUID() accountChangeTask?.cancel() accountsTask?.cancel() uploadTask?.cancel() pendingUploadConfirmation = nil - uploadChoice = nil - uploadConfirmation = nil + isShowingUploadConfirmation = false clearAccountView() banner?.dismiss() - if isPresentingPasscode { + if wasPresentingPasscode { cancel(with: .noAccount) } else { showAlert(description: "_no_active_account_") { [weak self] in self?.cancel(with: .noAccount) } @@ -330,23 +334,11 @@ final class NCShareViewModel: NSObject, ObservableObject { guard !isClosing else { return } isClosing = true alertDismissAction = nil - connectionMonitor?.cancel() - for request in thumbnailRequests.values { QLThumbnailGenerator.shared.cancel(request) } + stopRequestWork() thumbnailRequests.removeAll() fileThumbnails.removeAll() - uploadConfirmation = nil + isShowingUploadConfirmation = false pendingUploadConfirmation = nil - uploadChoice = nil - 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) } if let error { requestContext?.cancelRequest(withError: error) } else { @@ -367,18 +359,16 @@ final class NCShareViewModel: NSObject, ObservableObject { } func createFolder() { - let session = sessionStore.getSession() - guard canUseFolderBrowser, isUploadDestinationReady, !isPreparingInput, inputErrorMessage.isEmpty, - let folder = selectedFolder else { return } - guard let capabilities = serverCapabilities, + 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.isClosing, self.folderBrowserID == browserID else { return } + 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) @@ -393,13 +383,13 @@ final class NCShareViewModel: NSObject, ObservableObject { // Load and expand its ancestors before scrolling to the new row. guard var branch = self.rootFolder else { return } while true { - guard !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID else { return } + 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 !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID else { return } + 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 !Task.isCancelled, !self.isClosing, self.folderBrowserID == browserID, + 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: { @@ -455,37 +445,28 @@ final class NCShareViewModel: NSObject, ObservableObject { try? FileManager.default.removeItem(at: inputFileURL(named: fileName)) self.fileNames.remove(at: index) if self.fileNames.isEmpty { - cancel(with: NCShareRequestError.noFiles) + cancel(with: .noFiles) } } -} - -extension NCShareViewModel { /// Require authentication once per foreground session without reopening after dismissal. private func requestPasscodeIfNeeded() { - if !NCPreferences().presentPasscode || isPasscodeAuthenticated { - isAuthenticated = true + guard access != .presentingPasscode, access != .unlocked else { return } + if !NCPreferences().presentPasscode { + access = .unprotected return } - isAuthenticated = false + access = .locked // The sheet's onDismiss resumes the passcode flow after its transition. - guard !isPresentingPasscode, pendingUploadConfirmation == nil, - let presenter else { return } - isPresentingPasscode = true + guard pendingUploadConfirmation == nil, let presenter else { return } + access = .presentingPasscode NCPasscodeView.present(from: presenter, allowCancel: true) { [weak self] result in - guard let self else { return } - self.isPresentingPasscode = false - switch result { - case .authenticated: - self.isPasscodeAuthenticated = true - if self.validateAccount() { - self.isAuthenticated = true - if self.accounts.isEmpty { self.loadAccounts() } - } - case .cancelled, .reset, .configured, .disabled: - self.cancel(with: .noAccount) - } + 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() } } } } @@ -517,8 +498,7 @@ struct NCShareUploadItem: Sendable { let fileName: String } -struct NCShareUploadConfirmation: Identifiable, Sendable { - let id = UUID() +struct NCShareUploadConfirmation: Sendable { let account: String let serverUrl: String let items: [NCShareUploadItem] @@ -551,7 +531,7 @@ extension NCShareViewModel { static func preview() -> NCShareViewModel { let model = NCShareViewModel() model.serverCapabilities = NKCapabilities.Capabilities() - model.isAuthenticated = true + 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) diff --git a/Share/NCShareLoaderItemProvider.swift b/Share/NCShareLoaderItemProvider.swift index 63b15fd5aa..0c9056efbf 100644 --- a/Share/NCShareLoaderItemProvider.swift +++ b/Share/NCShareLoaderItemProvider.swift @@ -28,7 +28,7 @@ final class NCShareLoaderItemProvider { private var finished = false private var cancelled = false - var isCancelled: Bool { + private var isCancelled: Bool { lock.lock() defer { lock.unlock() } return cancelled @@ -51,7 +51,9 @@ final class NCShareLoaderItemProvider { if cancelled { cancellation() } } - func finish(_ result: Result) { + func finish(_ operation: () throws -> Value) { + guard !isCancelled else { return } + let result = Result(catching: operation) lock.lock() guard !finished else { lock.unlock(); return } finished = true @@ -190,14 +192,10 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let url else { throw CocoaError(.fileReadUnknown) } - let result = try copy(source: url, name: name, fallbackName: fallbackName, type: type, directory: directory) - pending.finish(.success(result)) - } catch { - pending.finish(.failure(error)) + return try copy(source: url, name: name, fallbackName: fallbackName, type: type, directory: directory) } } return { progress.cancel() } @@ -207,15 +205,10 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let data else { throw CocoaError(.fileReadUnknown) } - let destination = destination(name: name ?? fallbackName, type: type, directory: directory) - try data.write(to: destination, options: .atomic) - pending.finish(.success(destination.lastPathComponent)) - } catch { - pending.finish(.failure(error)) + return try write(data, name: name ?? fallbackName, type: type, directory: directory) } } return { progress.cancel() } @@ -225,16 +218,11 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let image = object as? UIImage, let data = autoreleasepool(invoking: { image.pngData() }) else { throw CocoaError(.fileReadCorruptFile) } - let destination = destination(name: name ?? fallbackName, type: .png, directory: directory) - try data.write(to: destination, options: .atomic) - pending.finish(.success(destination.lastPathComponent)) - } catch { - pending.finish(.failure(error)) + return try write(data, name: name ?? fallbackName, type: .png, directory: directory) } } return { progress.cancel() } @@ -244,15 +232,10 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let text = object as? String else { throw CocoaError(.fileReadCorruptFile) } - let destination = destination(name: name ?? fallbackName, type: .utf8PlainText, directory: directory) - try Data(text.utf8).write(to: destination, options: .atomic) - pending.finish(.success(destination.lastPathComponent)) - } catch { - pending.finish(.failure(error)) + return try write(Data(text.utf8), name: name ?? fallbackName, type: .utf8PlainText, directory: directory) } } return { progress.cancel() } @@ -264,15 +247,10 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let text = object as? NSAttributedString else { throw CocoaError(.fileReadCorruptFile) } - let destination = destination(name: name ?? fallbackName, type: .utf8PlainText, directory: directory) - try Data(text.string.utf8).write(to: destination, options: .atomic) - pending.finish(.success(destination.lastPathComponent)) - } catch { - pending.finish(.failure(error)) + return try write(Data(text.string.utf8), name: name ?? fallbackName, type: .utf8PlainText, directory: directory) } } return { progress.cancel() } @@ -282,20 +260,16 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + 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) - pending.finish(.success(.file(name))) - } else { - pending.finish(.success(.remote(url))) + return .file(name) } - } catch { - pending.finish(.failure(error)) + return .remote(url) } } return { progress.cancel() } @@ -317,8 +291,7 @@ final class NCShareLoaderItemProvider { 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 - guard !pending.isCancelled else { return } - do { + pending.finish { if let error { throw error } guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), @@ -326,9 +299,7 @@ final class NCShareLoaderItemProvider { 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) - pending.finish(.success(destination.lastPathComponent)) - } catch { - pending.finish(.failure(error)) + return destination.lastPathComponent } } task.resume() @@ -336,6 +307,12 @@ final class NCShareLoaderItemProvider { } } + 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() } } diff --git a/Share/View/NCShareView.swift b/Share/View/NCShareView.swift index 05b836e7bf..c53dea4305 100644 --- a/Share/View/NCShareView.swift +++ b/Share/View/NCShareView.swift @@ -9,6 +9,8 @@ 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 @@ -16,68 +18,50 @@ struct NCShareView: View { var body: some View { NavigationStack { - Group { - if model.rootFolder != nil && model.isAuthenticated { - VStack(spacing: 0) { - statusView - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - GeometryReader { geometry in - if UIDevice.current.userInterfaceIdiom == .phone && geometry.size.width > geometry.size.height { - VStack(spacing: 0) { - 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)) - } + 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) - uploadButton - .padding(.top, 10) - .padding(.bottom, 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)) } - .background(Color(uiColor: .systemGroupedBackground)) + .padding(.horizontal, 16) } else { - VStack(spacing: 0) { - folderList() - VStack(spacing: 10) { - fileList() - uploadButton - } - .padding(.top, 10) - .padding(.bottom, 16) - .background(Color(uiColor: .systemGroupedBackground)) + folderList() + } + VStack(spacing: 10) { + if !sideBySide { + fileList() } + uploadButton } + .padding(.top, 10) + .padding(.bottom, 16) + .background(Color(uiColor: .systemGroupedBackground)) } } - .background(Color(uiColor: .systemGroupedBackground)) } else { - VStack(spacing: 0) { - Group { - if !model.alertMessage.isEmpty { - statusError(model.alertMessage) { model.acknowledgeAlert() } - } else { - ProgressView(NSLocalizedString("_request_in_progress_", comment: "")) - .cappedFont(.subheadline, maxDynamicType: .accessibility2) - .padding() - } - } - .frame(maxWidth: .infinity) - .background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - Spacer(minLength: 0) - } - .background(Color(uiColor: .systemGroupedBackground)) + Spacer(minLength: 0) } } + .background(Color(uiColor: .systemGroupedBackground)) .dynamicTypeSize(.xSmall ... .accessibility2) .background(Color(uiColor: .systemBackground)) - .sheet(item: $model.uploadConfirmation, onDismiss: { model.uploadConfirmationDismissed() }) { confirmation in - NCShareUploadConflictView(confirmation: confirmation, resolve: model.resolveUploadConfirmation) + .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) @@ -85,7 +69,7 @@ struct NCShareView: View { .toolbarBackground(.hidden, for: .navigationBar) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { - if model.rootFolder != nil && model.isAuthenticated { + if canShowContent { HStack(spacing: 8) { NCShareAccountMenu(model: model) .disabled(model.isLoading || model.isPreparingUpload || model.isSwitchingAccount || model.isLoadingAccounts) @@ -110,7 +94,11 @@ struct NCShareView: View { private var statusView: some View { if !model.alertMessage.isEmpty { statusError(model.alertMessage) { model.acknowledgeAlert() } - } else if !model.inputErrorMessage.isEmpty && model.isAuthenticated && !model.isLoadingAccounts { + } 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) { @@ -226,8 +214,7 @@ struct NCShareView: View { } .buttonStyle(.borderless) .tint(.blue) - .disabled(isRefreshing || !model.canUseFolderBrowser || !model.isUploadDestinationReady || - model.isPreparingInput || !model.inputErrorMessage.isEmpty) + .disabled(!model.canUseUploadDestination) } .textCase(nil) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) @@ -389,9 +376,7 @@ struct NCShareView: View { private var uploadButton: some View { NCShareUploadButton(fileCount: model.fileNames.count, isPreparing: model.isPreparingUpload, action: model.upload) - .disabled(isRefreshing || model.fileNames.isEmpty || !model.isUploadDestinationReady || model.isLoading || - model.isPreparingUpload || model.isPreparingInput || !model.isAccountReady || - model.isLoadingAccounts || !model.inputErrorMessage.isEmpty) + .disabled(!model.canUpload) } } From be7be74769fc7578c4b239a44228f85ceda2a2bb Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 10:39:59 +0100 Subject: [PATCH 34/42] test: use AppConfig login for passcode UI tests Provision passcode test accounts from launch environment credentials in debug builds, while keeping browser authentication as the default for other UI tests. Signed-off-by: Marino Faggiana --- Tests/NextcloudUITests/BaseUIXCTestCase.swift | 21 ++++++++++++------- Tests/NextcloudUITests/PasscodeUITests.swift | 5 ++++- iOSClient/Login/NCLogin.swift | 12 +++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Tests/NextcloudUITests/BaseUIXCTestCase.swift b/Tests/NextcloudUITests/BaseUIXCTestCase.swift index 734d3f0b82..539a1e2149 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,18 @@ class BaseUIXCTestCase: XCTestCase { throw UITestError.waitForExistence(login) } login.tap() + if !usingAppConfig { + try await authenticateInBrowser() + } + + let sidebar = app.buttons["openSidebar"].firstMatch + guard sidebar.await(timeout: 30) || 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 +119,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/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 From d88e16830befa5997e2b55acc4d5e8a3e7a28aeb Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 12:51:58 +0100 Subject: [PATCH 35/42] ci: extend Xcode test timeouts and remove explicit test iterations Signed-off-by: Marino Faggiana --- .github/workflows/xcode.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 44ca3963b1..82fb75a9e4 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -62,7 +62,7 @@ jobs: test: name: Run Tests runs-on: xcode-27 - timeout-minutes: 40 + timeout-minutes: 50 needs: [build] if: github.event.pull_request.draft == false @@ -176,7 +176,7 @@ jobs: fi - name: Run Xcode Test - timeout-minutes: 25 + timeout-minutes: 35 shell: bash env: RUN_PASSCODE_TESTS: ${{ steps.passcode_changes.outputs.run }} @@ -191,7 +191,6 @@ jobs: -destination "${{ env.DESTINATION }}" \ -derivedDataPath "DerivedData" \ "${test_selection[@]}" \ - -test-iterations 3 \ -retry-tests-on-failure \ -test-timeouts-enabled YES \ -default-test-execution-time-allowance 600 \ From b06590dea04c3d8f3662a52feb22bba45446de50 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 14:15:09 +0100 Subject: [PATCH 36/42] ci: validate app password login before passcode tests Increase the test job timeout to 60 minutes and the Xcode test timeout to 45 minutes. Signed-off-by: Marino Faggiana --- .github/workflows/xcode.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 82fb75a9e4..99679c61de 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -62,7 +62,7 @@ jobs: test: name: Run Tests runs-on: xcode-27 - timeout-minutes: 50 + timeout-minutes: 60 needs: [build] if: github.event.pull_request.draft == false @@ -156,6 +156,22 @@ jobs: - name: Assert Nextcloud Server Status run: curl --fail --show-error --silent --max-time 5 --retry 5 --retry-delay 2 --retry-max-time 30 --retry-all-errors http://localhost:8080/status.php + - name: Assert Nextcloud AppConfig Login + if: steps.passcode_changes.outputs.run == 'true' + shell: bash + run: | + curl --fail --show-error --silent --max-time 15 --user admin:admin \ + --header 'OCS-APIRequest: true' \ + 'http://localhost:8080/ocs/v2.php/core/getapppassword' | \ + php -r ' + libxml_use_internal_errors(true); + $response = simplexml_load_string(stream_get_contents(STDIN), SimpleXMLElement::class, LIBXML_NONET); + if ($response === false || trim((string) ($response->data->apppassword ?? "")) === "") { + fwrite(STDERR, "Nextcloud did not return an app password.\n"); + exit(1); + } + ' + - name: Boot iOS Simulator run: | xcrun simctl boot "iPhone 17" @@ -176,7 +192,7 @@ jobs: fi - name: Run Xcode Test - timeout-minutes: 35 + timeout-minutes: 45 shell: bash env: RUN_PASSCODE_TESTS: ${{ steps.passcode_changes.outputs.run }} From b1f50dca77d61ea8cd9e04acfbcb8f4b8d415ef8 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 10 Nov 2026 17:09:21 +0100 Subject: [PATCH 37/42] fix: avoid Photos permission prompt during background upload cleanup Signed-off-by: Marino Faggiana --- .../NCBackgroundUploadExtensionManager.swift | 5 +++++ 1 file changed, 5 insertions(+) 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")) From 6f99bc7ce359faadf7d050591c4969933e0d8d8a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 11 Nov 2026 09:18:25 +0100 Subject: [PATCH 38/42] test: allow 90 seconds for AppConfig login in CI Run the presented-sheet passcode test once to validate the timeout. Assisted-by: Codex:gpt-6 Signed-off-by: Marino Faggiana --- .github/workflows/xcode.yml | 6 ++---- Tests/NextcloudUITests/BaseUIXCTestCase.swift | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 99679c61de..9b3b04cd3f 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -199,15 +199,13 @@ jobs: run: | set -o pipefail test_selection=() - if [ "$RUN_PASSCODE_TESTS" != "true" ]; then - test_selection+=(-skip-testing:NextcloudUITests/PasscodeUITests) - fi + # Temporary diagnostic for the AppConfig login timeout. + test_selection+=(-only-testing:NextcloudUITests/PasscodeUITests/testAppLockAppearsAbovePresentedSheet) xcodebuild test-without-building \ -xctestrun $(find . -type f -name "*.xctestrun") \ -destination "${{ env.DESTINATION }}" \ -derivedDataPath "DerivedData" \ "${test_selection[@]}" \ - -retry-tests-on-failure \ -test-timeouts-enabled YES \ -default-test-execution-time-allowance 600 \ -maximum-test-execution-time-allowance 600 \ diff --git a/Tests/NextcloudUITests/BaseUIXCTestCase.swift b/Tests/NextcloudUITests/BaseUIXCTestCase.swift index 539a1e2149..d718cb5d71 100644 --- a/Tests/NextcloudUITests/BaseUIXCTestCase.swift +++ b/Tests/NextcloudUITests/BaseUIXCTestCase.swift @@ -84,7 +84,8 @@ class BaseUIXCTestCase: XCTestCase { } let sidebar = app.buttons["openSidebar"].firstMatch - guard sidebar.await(timeout: 30) || app.buttons["accountSwitcher"].exists else { + let loginTimeout: TimeInterval = usingAppConfig ? 90 : 30 + guard sidebar.await(timeout: loginTimeout) || app.buttons["accountSwitcher"].exists else { attachAuthenticationDiagnostics() throw UITestError.waitForExistence(sidebar) } From b44bf196dbff4616acbdcb8fc690d64a129dfac4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 11 Nov 2026 10:27:00 +0100 Subject: [PATCH 39/42] ci: use Xcode 27.2 and iOS 27.2 simulator Assisted-by: Codex:gpt-6 Signed-off-by: Marino Faggiana --- .github/workflows/xcode.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 9b3b04cd3f..ff4d428415 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 From 67dfaaaad4e5d7111f6e1b5b99af15d645cc597a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 11 Nov 2026 10:36:19 +0100 Subject: [PATCH 40/42] ci: restore bounded Xcode test timeouts --- .github/workflows/xcode.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index ff4d428415..8aaad4acc4 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -62,7 +62,7 @@ jobs: test: name: Run Tests runs-on: xcode-27 - timeout-minutes: 60 + timeout-minutes: 40 needs: [build] if: github.event.pull_request.draft == false @@ -192,7 +192,7 @@ jobs: fi - name: Run Xcode Test - timeout-minutes: 45 + timeout-minutes: 25 shell: bash env: RUN_PASSCODE_TESTS: ${{ steps.passcode_changes.outputs.run }} From 6928107d70ee8d85c7f36ba2daa277313628df8f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 11 Nov 2026 11:03:31 +0100 Subject: [PATCH 41/42] ci: run only unit tests in GitHub Actions --- .github/workflows/xcode.yml | 73 ++----------------------------------- 1 file changed, 4 insertions(+), 69 deletions(-) diff --git a/.github/workflows/xcode.yml b/.github/workflows/xcode.yml index 8aaad4acc4..bf3a7df058 100644 --- a/.github/workflows/xcode.yml +++ b/.github/workflows/xcode.yml @@ -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 @@ -156,22 +120,6 @@ jobs: - name: Assert Nextcloud Server Status run: curl --fail --show-error --silent --max-time 5 --retry 5 --retry-delay 2 --retry-max-time 30 --retry-all-errors http://localhost:8080/status.php - - name: Assert Nextcloud AppConfig Login - if: steps.passcode_changes.outputs.run == 'true' - shell: bash - run: | - curl --fail --show-error --silent --max-time 15 --user admin:admin \ - --header 'OCS-APIRequest: true' \ - 'http://localhost:8080/ocs/v2.php/core/getapppassword' | \ - php -r ' - libxml_use_internal_errors(true); - $response = simplexml_load_string(stream_get_contents(STDIN), SimpleXMLElement::class, LIBXML_NONET); - if ($response === false || trim((string) ($response->data->apppassword ?? "")) === "") { - fwrite(STDERR, "Nextcloud did not return an app password.\n"); - exit(1); - } - ' - - name: Boot iOS Simulator run: | xcrun simctl boot "iPhone 17" @@ -182,30 +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=() - # Temporary diagnostic for the AppConfig login timeout. - test_selection+=(-only-testing:NextcloudUITests/PasscodeUITests/testAppLockAppearsAbovePresentedSheet) + # 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[@]}" \ + -only-testing:NextcloudUnitTests \ -test-timeouts-enabled YES \ -default-test-execution-time-allowance 600 \ -maximum-test-execution-time-allowance 600 \ From 1e5b033de9e48b89f34bf50ab7089ef208f49001 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 12 Nov 2026 09:31:57 +0100 Subject: [PATCH 42/42] chore: bump marketing version to 35.3.0 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 571f9c4602..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 */; }; @@ -84,8 +86,6 @@ 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 */; }; - AB1109260000000000010101 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; - A71107260000000000000101 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.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 */; }; @@ -97,13 +97,13 @@ 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 */; }; - AB1109260000000000010102 /* NCShareUploadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1109260000000000010001 /* NCShareUploadButton.swift */; }; - A71107260000000000000102 /* NCShareFilenameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A71107260000000000000100 /* NCShareFilenameView.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 */; }; @@ -1305,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 = ""; }; @@ -1408,12 +1409,11 @@ 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 = ""; }; - AB1109260000000000010001 /* NCShareUploadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareUploadButton.swift; sourceTree = ""; }; - A71107260000000000000100 /* NCShareFilenameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareFilenameView.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 = ""; }; @@ -6694,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 = ""; @@ -6760,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 = "";