diff --git a/ios-sdk b/ios-sdk index 4dbc4ad41..42b6f9689 160000 --- a/ios-sdk +++ b/ios-sdk @@ -1 +1 @@ -Subproject commit 4dbc4ad4157eab1f9514aa8fc48d0148d228aeb4 +Subproject commit 42b6f9689594c81896c6e0d9aa2362c20a21b38c diff --git a/ownCloud.xcodeproj/project.pbxproj b/ownCloud.xcodeproj/project.pbxproj index 2bcf4fc09..e0c507662 100644 --- a/ownCloud.xcodeproj/project.pbxproj +++ b/ownCloud.xcodeproj/project.pbxproj @@ -496,6 +496,7 @@ DCC5E4472326564F002E5B84 /* NSObject+AnnotatedProperties.h in Headers */ = {isa = PBXBuildFile; fileRef = DCC5E444232654DE002E5B84 /* NSObject+AnnotatedProperties.h */; settings = {ATTRIBUTES = (Public, ); }; }; DCC6564A20C9B7E400110A97 /* FileProviderExtension.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC6564920C9B7E400110A97 /* FileProviderExtension.m */; }; DCC6566520C9B7E400110A97 /* ownCloud File Provider.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCC6564620C9B7E300110A97 /* ownCloud File Provider.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DCC73F2E2B86BC960009A210 /* PasswordComposerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */; }; DCC832DE242C0C3700153F8C /* DisplaySleepPreventer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC832DD242C0C3700153F8C /* DisplaySleepPreventer.swift */; }; DCC832F1242CC27B00153F8C /* NotificationMessagePresenter.h in Headers */ = {isa = PBXBuildFile; fileRef = DCC832E6242CB18700153F8C /* NotificationMessagePresenter.h */; settings = {ATTRIBUTES = (Public, ); }; }; DCC832F2242CC28400153F8C /* NotificationMessagePresenter.m in Sources */ = {isa = PBXBuildFile; fileRef = DCC832E7242CB18700153F8C /* NotificationMessagePresenter.m */; }; @@ -1576,6 +1577,7 @@ DCC6564920C9B7E400110A97 /* FileProviderExtension.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FileProviderExtension.m; sourceTree = ""; }; DCC6565120C9B7E400110A97 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DCC6565220C9B7E400110A97 /* ownCloud_File_Provider.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ownCloud_File_Provider.entitlements; sourceTree = ""; }; + DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordComposerViewController.swift; sourceTree = ""; }; DCC832DD242C0C3700153F8C /* DisplaySleepPreventer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySleepPreventer.swift; sourceTree = ""; }; DCC832E1242C0EAC00153F8C /* MessageSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSelector.swift; sourceTree = ""; }; DCC832E6242CB18700153F8C /* NotificationMessagePresenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotificationMessagePresenter.h; sourceTree = ""; }; @@ -2002,6 +2004,7 @@ DCE4E43424C199860051722F /* Actions */, DC16213C2B8FF02500EB17F8 /* Sidebar Items */, 399EA6ED25E6544000B6FF11 /* Sharing */, + DCC73F2C2B86BC170009A210 /* Password Composer */, DCE4E42F24C1963F0051722F /* User Interface */, ); path = Client; @@ -3252,6 +3255,14 @@ path = "ownCloud File Provider"; sourceTree = ""; }; + DCC73F2C2B86BC170009A210 /* Password Composer */ = { + isa = PBXGroup; + children = ( + DCC73F2D2B86BC960009A210 /* PasswordComposerViewController.swift */, + ); + path = "Password Composer"; + sourceTree = ""; + }; DCC832D1242BB3E900153F8C /* Messages */ = { isa = PBXGroup; children = ( @@ -4872,6 +4883,7 @@ DCA2EDE4279B1789001F04E6 /* ResourceItemIcon.swift in Sources */, DC8E99E8297F3BA700594697 /* ActionTapGestureRecognizer.swift in Sources */, DCB5D56B2861BEBE004AF425 /* SearchViewController.swift in Sources */, + DCC73F2E2B86BC960009A210 /* PasswordComposerViewController.swift in Sources */, DCB1B8A729C75EBD00BFF393 /* ThemeCSS+AutoSelectors.swift in Sources */, 0287DD7D249131E000C912CA /* AppStatistics.swift in Sources */, DCB1B8A429C73DB800BFF393 /* ThemeCSSRecord.swift in Sources */, diff --git a/ownCloud/Resources/de.lproj/Localizable.strings b/ownCloud/Resources/de.lproj/Localizable.strings index f4761f3d8..90e0bb5b8 100644 --- a/ownCloud/Resources/de.lproj/Localizable.strings +++ b/ownCloud/Resources/de.lproj/Localizable.strings @@ -627,9 +627,17 @@ "Share with" = "Teilen mit"; "Add" = "Hinzufügen"; +"Set" = "Setzen"; +"Generate" = "Generieren"; "Save changes" = "Änderung speichern"; "Enter password" = "Passwort eingeben"; +"Change password" = "Passwort ändern"; +"Show" = "Zeigen"; +"Hide" = "Verbergen"; + +"{{itemName}} ({{link}}) | password: {{password}}" = "{{itemName}} ({{link}}) | Passwort: {{password}}"; +"{{link}} | password: {{password}}" = "{{link}} | Passwort: {{password}}"; /* Quick Access view */ "Quick Access" = "Schnellzugriff"; diff --git a/ownCloud/Resources/en.lproj/Localizable.strings b/ownCloud/Resources/en.lproj/Localizable.strings index 30637d3ba..f9d6e8b10 100644 --- a/ownCloud/Resources/en.lproj/Localizable.strings +++ b/ownCloud/Resources/en.lproj/Localizable.strings @@ -534,7 +534,7 @@ "Shared with {{recipients}}" = "Shared with {{recipients}}"; "Expires {{expirationDate}}" = "Expires {{expirationDate}}"; "Share {{itemName}}" = "Share {{itemName}}"; -"Create link" = "Create link"; +"Create" = "Create"; "Invite" = "Invite"; "Invite Recipient" = "Invite Recipient"; "Recipients" = "Recipients"; @@ -556,7 +556,6 @@ "Shared with" = "Shared with"; "Remove Recipient failed" = "Remove Recipient failed"; "Remove Recipient" = "Remove Recipient"; -"Create" = "Create"; "Change" = "Change"; "Recipients can view or download contents." = "Recipients can view or download contents."; "Recipients can view, download, edit, delete and upload contents." = "Recipients can view, download, edit, delete and upload contents."; @@ -627,9 +626,17 @@ "Share with" = "Share with"; "Add" = "Add"; +"Set" = "Set"; +"Generate" = "Generate"; "Save changes" = "Save changes"; "Enter password" = "Enter password"; +"Change password" = "Change password"; +"Show" = "Show"; +"Hide" = "Hide"; + +"{{itemName}} ({{link}}) | password: {{password}}" = "{{itemName}} ({{link}}) | password: {{password}}"; +"{{link}} | password: {{password}}" = "{{link}} | password: {{password}}"; /* Quick Access search suggestions */ "Quick Access" = "Quick Access"; diff --git a/ownCloudAppShared/Client/Password Composer/PasswordComposerViewController.swift b/ownCloudAppShared/Client/Password Composer/PasswordComposerViewController.swift new file mode 100644 index 000000000..5a148b067 --- /dev/null +++ b/ownCloudAppShared/Client/Password Composer/PasswordComposerViewController.swift @@ -0,0 +1,286 @@ +// +// PasswordComposerViewController.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 23.02.24. +// Copyright © 2024 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2024, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK + +class PasswordComposerViewController: UIViewController { + typealias ResultHandler = (_ password: String?, _ cancelled: Bool) -> Void + + var resultHandler: ResultHandler? + + let passwordLabel = ThemeCSSLabel(withSelectors: [ .label, .secondary ]) + let passwordFieldContainer = ThemeCSSView(withSelectors: [ .cell ]) + let passwordField = ThemeCSSTextField() + + let componentToolbar = SegmentView(with: [], truncationMode: .none, scrollable: false) + + let validationReportContainerView = ThemeCSSView(withSelectors: [ .cell ]) + + lazy var showPasswordSegment: SegmentViewItem = { + return SegmentViewItem.button(title: "Show".localized, customizeButton: { _, config in + var buttonConfig = config + buttonConfig.image = OCSymbol.icon(forSymbolName: "eye")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small)) + buttonConfig.imagePadding = 5 + return buttonConfig + }, action: UIAction(handler: { [weak self] _ in + self?.showPassword = true + })) + }() + lazy var hidePasswordSegment: SegmentViewItem = { + return SegmentViewItem.button(title: "Hide".localized, customizeButton: { _, config in + var buttonConfig = config + buttonConfig.image = OCSymbol.icon(forSymbolName: "eye.slash")?.applyingSymbolConfiguration(UIImage.SymbolConfiguration(scale: .small)) + buttonConfig.imagePadding = 5 + return buttonConfig + }, action: UIAction(handler: { [weak self] _ in + self?.showPassword = false + })) + }() + lazy var generatePasswordSegment: SegmentViewItem = { + return SegmentViewItem.button(title: "Generate".localized, action: UIAction(handler: { [weak self] _ in + self?.generatePassword() + })) + }() + lazy var copyPasswordSegment: SegmentViewItem = { + return SegmentViewItem.button(title: "Copy".localized, action: UIAction(handler: { [weak self] _ in + self?.copyToClipboard() + })) + }() + + var saveButton: UIBarButtonItem? + + var passwordPolicy: OCPasswordPolicy + + init(password: String, policy: OCPasswordPolicy, saveButtonTitle: String, resultHandler: @escaping ResultHandler) { + self.passwordPolicy = policy + + super.init(nibName: nil, bundle: nil) + + defer { + // Placing this in a defer block makes sure that didSet is called for the respective properties + self.password = password + self.showPassword = false + } + + self.resultHandler = resultHandler + + saveButton = UIBarButtonItem(title: saveButtonTitle, style: .done, target: self, action: #selector(save)) + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Cancel".localized, style: .plain, target: self, action: #selector(cancel)) + navigationItem.rightBarButtonItem = saveButton + navigationItem.title = "Password".localized + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let rootView = ThemeCSSView(withSelectors: [ .grouped, .collection ]) + let padding = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + let labelFieldSpacing: CGFloat = 10 + let fieldToolbarSpacing: CGFloat = 15 + let toolbarValidationReportSpacing: CGFloat = 15 + + passwordLabel.translatesAutoresizingMaskIntoConstraints = false + passwordFieldContainer.translatesAutoresizingMaskIntoConstraints = false + passwordField.translatesAutoresizingMaskIntoConstraints = false + componentToolbar.translatesAutoresizingMaskIntoConstraints = false + componentToolbar.setContentHuggingPriority(.defaultHigh, for: .horizontal) + validationReportContainerView.translatesAutoresizingMaskIntoConstraints = false + + passwordFieldContainer.layer.cornerRadius = 5 + validationReportContainerView.layer.cornerRadius = 10 + + passwordField.cssSelectors = [ .cell ] + passwordFieldContainer.embed(toFillWith: passwordField, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)) + + componentToolbar.insets = .zero + componentToolbar.itemSpacing = 0 + + rootView.addSubview(passwordLabel) + rootView.addSubview(passwordFieldContainer) + rootView.addSubview(componentToolbar) + rootView.addSubview(validationReportContainerView) + + passwordLabel.text = "Password".localized + passwordLabel.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize) + + passwordField.placeholder = "Password".localized + passwordField.clearButtonMode = .always + passwordField.addAction(UIAction(handler: { [weak self] _ in + self?.passwordChanged() + }), for: .editingChanged) + + rootView.addConstraints([ + passwordLabel.topAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.topAnchor, constant: padding.top), + passwordLabel.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), + passwordLabel.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), + + passwordFieldContainer.topAnchor.constraint(equalTo: passwordLabel.bottomAnchor, constant: labelFieldSpacing), + passwordFieldContainer.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), + passwordFieldContainer.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), + + componentToolbar.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: fieldToolbarSpacing), + componentToolbar.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left - 5), + componentToolbar.trailingAnchor.constraint(lessThanOrEqualTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right), + + validationReportContainerView.topAnchor.constraint(equalTo: componentToolbar.bottomAnchor, constant: toolbarValidationReportSpacing), + validationReportContainerView.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: padding.left), + validationReportContainerView.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -padding.right) + ]) + + view = rootView + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + validatePasssword() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + passwordField.becomeFirstResponder() + } + + func passwordChanged() { + password = passwordField.text ?? "" + } + + func validatePasssword() { + let report = passwordPolicy.validate(password) + var lines : [UIView] = [] + var failures: Int = 0 + + for rule in report.rules { + var ruleDescription: String? = rule.localizedDescription + + if !(rule is OCPasswordPolicyRuleCharacters), let result = report.result(for: rule) { + ruleDescription = result + } + + if let ruleDescription { + let passedValidation = report.passedValidation(for: rule) + let symbolConfiguration = UIImage.SymbolConfiguration(hierarchicalColor: passedValidation ? .systemGreen : .systemRed) + let line = SegmentView(with: [ + SegmentViewItem(with: UIImage(systemName: passedValidation ? "checkmark.circle.fill" : "xmark.circle.fill")?.withConfiguration(symbolConfiguration), iconRenderingMode: .automatic, title: ruleDescription) + ], truncationMode: .truncateTail) + line.translatesAutoresizingMaskIntoConstraints = false + line.insets = .zero + + if passedValidation { + lines.append(line) + } else { + lines.insert(line, at: failures) + failures += 1 + } + } + } + + for subview in validationReportContainerView.subviews { + subview.removeFromSuperview() + } + + validationReportContainerView.embedVertically(views: lines, insets: NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), enclosingAnchors: validationReportContainerView.safeAreaAnchorSet, centered: false) + + saveButton?.isEnabled = report.passedValidation + } + + func updateSegments() { + var items: [SegmentViewItem] = [] + + // Show/Hide password + if showPassword { + items.append(hidePasswordSegment) + } else { + items.append(showPasswordSegment) + } + + // Generate password + items.append(SegmentViewItem(title: "|", style: .label)) + items.append(generatePasswordSegment) + + // Copy password + if password.count > 0 { + items.append(SegmentViewItem(title: "|", style: .label)) + items.append(copyPasswordSegment) + } + + if componentToolbar.items != items { + componentToolbar.items = items + } + } + + var password: String { + get { + return passwordField.text ?? "" + } + + set { + passwordField.text = newValue + + updateSegments() + validatePasssword() + } + } + var showPassword: Bool = false { + didSet { + passwordField.isSecureTextEntry = !showPassword + updateSegments() + } + } + + func generatePassword() { + var generatedPassword: String? + do { + try generatedPassword = passwordPolicy.generatePassword(withMinLength: nil, maxLength: nil) + } catch let error as NSError { + Log.error("Error generating password: \(error)") + } + if let generatedPassword { + password = generatedPassword + } + } + + func copyToClipboard() { + UIPasteboard.general.string = password + + _ = NotificationHUDViewController(on: self, title: "Password".localized, subtitle: "The password was copied to the clipboard".localized, completion: nil) + } + + func viewControllerForPresentation() -> ThemeNavigationController { + let navigationViewController = ThemeNavigationController(rootViewController: self) + navigationViewController.cssSelectors = [ .modal ] + + return navigationViewController + } + + @objc func save() { + presentingViewController?.dismiss(animated: true, completion: { + self.resultHandler?(self.password, false) + }) + } + + @objc func cancel() { + presentingViewController?.dismiss(animated: true, completion: { + self.resultHandler?(nil, true) + }) + } +} diff --git a/ownCloudAppShared/Client/Sharing/ShareViewController.swift b/ownCloudAppShared/Client/Sharing/ShareViewController.swift index 0eba7c02d..d4e1ce865 100644 --- a/ownCloudAppShared/Client/Sharing/ShareViewController.swift +++ b/ownCloudAppShared/Client/Sharing/ShareViewController.swift @@ -243,6 +243,14 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe linksSectionContext.originatingViewController = self revoke(in: clientContext, when: [ .connectionClosed, .connectionOffline ]) + + // Add defaults for creation + if self.type == .link, self.mode == .create, share == nil, expirationDate == nil { + if clientContext.core?.connection.capabilities?.publicSharingExpireDateAddDefaultDate == true, + let numberOfDays = clientContext.core?.connection.capabilities?.publicSharingDefaultExpireDateDays { + expirationDate = Date(timeIntervalSinceNow: numberOfDays.doubleValue * (24 * 60 * 60)) + } + } } required public init?(coder: NSCoder) { @@ -270,11 +278,15 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe navigationItem.titleLabelText = navigationTitle // Add bottom button bar - let title = (mode == .create) ? ((type == .link) ? "Create link".localized : "Invite".localized) : "Save changes".localized + let isLinkCreation = (mode == .create) && (type == .link) + let title = (mode == .create) ? ((type == .link) ? "Share".localized : "Invite".localized) : "Save changes".localized + let altTitle = isLinkCreation ? "Create".localized : nil - bottomButtonBar = BottomButtonBar(selectButtonTitle: title, cancelButtonTitle: "Cancel".localized, hasCancelButton: true, selectAction: UIAction(handler: { [weak self] _ in + bottomButtonBar = BottomButtonBar(selectButtonTitle: title, alternativeButtonTitle: altTitle, cancelButtonTitle: "Cancel".localized, hasAlternativeButton: isLinkCreation, hasCancelButton: true, selectAction: UIAction(handler: { [weak self] _ in + self?.save(andShare: isLinkCreation) + }), alternativeAction: isLinkCreation ? UIAction(handler: { [weak self] _ in self?.save() - }), cancelAction: UIAction(handler: { [weak self] _ in + }) : nil, cancelAction: UIAction(handler: { [weak self] _ in self?.complete() })) bottomButtonBar?.showActivityIndicatorWhileModalActionRunning = mode != .edit @@ -533,28 +545,90 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe // MARK: - State func updateState() { + var createIsEnabled: Bool + switch type { case .link: - bottomButtonBar?.selectButton.isEnabled = (location != nil) && (role != nil) && (permissions != nil) + createIsEnabled = (location != nil) && (role != nil) && (permissions != nil) case .share: - bottomButtonBar?.selectButton.isEnabled = (location != nil) && (recipient != nil) && (role != nil) && (permissions != nil) + createIsEnabled = (location != nil) && (recipient != nil) && (role != nil) && (permissions != nil) + } + + // Enforce password requirements + if hasPasswordOption && passwordRequired && !hasPassword { + createIsEnabled = false } + + // Enforce expiration date requirements + if hasExpirationOption && expirationDateRequired && (expirationDate == nil) { + createIsEnabled = false + } + + bottomButtonBar?.selectButton.isEnabled = createIsEnabled + bottomButtonBar?.alternativeButton.isEnabled = createIsEnabled } // MARK: - Options var passwordOption: OptionItem? - var password: String? - var removePassword: Bool = false + var password: String? { + didSet { + updateState() + } + } + var removePassword: Bool = false { + didSet { + updateState() + } + } + var passwordPolicy: OCPasswordPolicy { + return clientContext?.core?.connection.capabilities?.passwordPolicy ?? OCPasswordPolicy.default + } var expiryOption: OptionItem? var expirationDatePicker: UIDatePicker? - var expirationDate: Date? + var expirationDate: Date? { + didSet { + updateState() + } + } - func updateOptions() { - let hasPasswordOption = type == .link - let hasExpirationOption = true + var hasPasswordOption: Bool { + return type == .link + } + var passwordRequired: Bool { + if type == .link, let capabilities = clientContext?.core?.connection.capabilities { + if capabilities.publicSharingPasswordEnforced == true { + return true + } + if permissions?.contains(.read) == true && + permissions?.contains(.delete) == true && ( + permissions?.contains(.create) == true || + permissions?.contains(.update) == true) { + return capabilities.publicSharingPasswordEnforcedForReadWriteDelete == true + } + if permissions?.contains(.read) == true && ( + permissions?.contains(.create) == true || + permissions?.contains(.update) == true) { + return capabilities.publicSharingPasswordEnforcedForReadWrite == true + } + if permissions?.contains(.create) == true { + return capabilities.publicSharingPasswordEnforcedForUploadOnly == true + } + if permissions?.contains(.read) == true { + return capabilities.publicSharingPasswordEnforcedForReadOnly == true + } + } + return false + } + var hasExpirationOption: Bool { + return (type == .link) + } + var expirationDateRequired: Bool { + return type == .link ? (clientContext?.core?.connection.capabilities?.publicSharingExpireDateEnforceDateAndDaysDeterminesLastAllowedDate == true) : false + } + func updateOptions() { var options: [OCDataItem & OCDataItemVersioning] = [] // Password @@ -562,7 +636,33 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe var accessories: [UICellAccessory] = [] var details: [SegmentViewItem] = [] + let makeButton: (_ title: String, _ action: @escaping UIActionHandler) -> UIButton = { (title, action) in + var buttonConfig = UIButton.Configuration.plain() + buttonConfig.title = title + buttonConfig.contentInsets = .zero + + let button = ThemeCSSButton() + button.configuration = buttonConfig + button.addAction(UIAction(handler: action), for: .primaryActionTriggered) + + return button + } + if ((share?.protectedByPassword == true) && !removePassword) || (password != nil) { + if password != nil { + let copyButton = makeButton("Copy".localized, { [weak self] action in + if let self, let password = self.password { + UIPasteboard.general.string = password + _ = NotificationHUDViewController(on: self, title: "Password".localized, subtitle: "The password was copied to the clipboard".localized, completion: nil) + } + }) + + details.append(contentsOf: [ + SegmentViewItem(view: copyButton), + SegmentViewItem(title: "|", style: .label) + ]) + } + details.append(.detailText("******")) accessories = [ @@ -573,17 +673,19 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe })) ] } else { - var buttonConfig = UIButton.Configuration.plain() - buttonConfig.title = "Add".localized - buttonConfig.contentInsets = .zero + if passwordRequired { + details.append(.detailText("⚠️")) + } - let button = ThemeCSSButton() - button.configuration = buttonConfig - button.addAction(UIAction(handler: { [weak self] action in + let generateButton = makeButton("Generate".localized, { [weak self] action in + self?.generatePassword() + }) + + let addButton = makeButton("Set".localized, { [weak self] action in self?.requestPassword() - }), for: .primaryActionTriggered) + }) - details.append(SegmentViewItem(view: button)) + details.append(contentsOf: [SegmentViewItem(view: generateButton), SegmentViewItem(title: "|", style: .label), SegmentViewItem(view: addButton)]) } let content = UniversalItemListCell.Content(with: .text("Password".localized), iconSymbolName: "key.fill", accessories: accessories) @@ -591,9 +693,7 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe if passwordOption == nil { passwordOption = OptionItem(kind: .single, content: content, state: false, selectionAction: { [weak self] optionItem in - if self?.hasPassword == true { - self?.requestPassword() - } + self?.requestPassword() }) } else { passwordOption?.content = content @@ -615,6 +715,10 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe datePicker.preferredDatePickerStyle = .compact datePicker.datePickerMode = .date datePicker.minimumDate = .now + if clientContext?.core?.connection.capabilities?.publicSharingExpireDateEnforceDateAndDaysDeterminesLastAllowedDate == true, + let numberOfDays = clientContext?.core?.connection.capabilities?.publicSharingDefaultExpireDateDays { + datePicker.maximumDate = Date(timeIntervalSinceNow: numberOfDays.doubleValue * (24 * 60 * 60)) + } datePicker.date = expirationDate datePicker.addAction(UIAction(handler: { [weak self, weak datePicker] action in self?.expirationDate = datePicker?.date @@ -646,6 +750,10 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe self?.updateOptions() }), for: .primaryActionTriggered) + if expirationDateRequired { + details.append(.detailText("⚠️")) + } + details.append(SegmentViewItem(view: button)) } @@ -670,24 +778,37 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe return ((share?.protectedByPassword == true) && !removePassword) || (password != nil) } func requestPassword() { - let passwordPrompt = UIAlertController(title: "Enter password".localized, message: nil, preferredStyle: .alert) - - passwordPrompt.addTextField(configurationHandler: { textField in - textField.placeholder = "Password".localized - textField.isSecureTextEntry = true + let passwordViewController = PasswordComposerViewController(password: password ?? "", policy: passwordPolicy, saveButtonTitle: "Set".localized, resultHandler: { [weak self] password, cancelled in + if !cancelled, let password { + self?.password = password + self?.updateOptions() + } }) + let navigationViewController = passwordViewController.viewControllerForPresentation() - passwordPrompt.addAction(UIAlertAction(title: "Cancel".localized, style: .cancel)) - passwordPrompt.addAction(UIAlertAction(title: "OK".localized, style: .default, handler: { [weak self, weak passwordPrompt] action in - self?.password = passwordPrompt?.textFields?.first?.text - self?.updateOptions() - })) + if mode == .edit, hasPassword { + passwordViewController.navigationItem.title = "Change password".localized + } - self.clientContext?.present(passwordPrompt, animated: true) + self.clientContext?.present(navigationViewController, animated: true) + } + func generatePassword() { + var generatedPassword: String? + do { + try generatedPassword = passwordPolicy.generatePassword(withMinLength: nil, maxLength: nil) + } catch let error as NSError { + Log.error("Error generating password: \(error)") + } + if let generatedPassword { + self.password = generatedPassword + self.updateOptions() + } } // MARK: - Save (edit + create) - func save() { + func save(andShare: Bool = false) { + let presentingViewController = UIDevice.current.isIpad ? self : self.presentingViewController + switch mode { case .create: var newShare: OCShare? @@ -732,6 +853,67 @@ open class ShareViewController: CollectionViewController, SearchViewControllerDe if let error { self.showError(error) } else { + if let url = share?.url, andShare { + let existingCompletionHandler = UIDevice.current.isIpad ? { (share) in + // On iPad, first show Share Sheet, then close ShareViewController + self.complete(with: share) + } : self.completionHandler // On iPhone, first close ShareViewController, then show Share Sheet + + let handleResultAndShowShareSheet: CompletionHandler = { (share) in + let absoluteURLString = url.absoluteString + var shareMessage: String? + + if let password = self.password { + // Message consists of Link + Password + if let displayName = self.location?.displayName(in: nil) { + shareMessage = "{{itemName}} ({{link}}) | password: {{password}}".localized([ + "itemName" : displayName, + "link" : absoluteURLString, + "password" : password + ]) + } else { + shareMessage = "{{link}} | password: {{password}}".localized([ + "link" : absoluteURLString, + "password" : password + ]) + } + } else { + // Message consists of Link only + shareMessage = absoluteURLString + } + + if let shareMessage, let presentingViewController { + // Show Share Sheet + OnMainThread { + let shareViewController = UIActivityViewController(activityItems: [shareMessage], applicationActivities:nil) + + if UIDevice.current.isIpad { + shareViewController.popoverPresentationController?.sourceView = self.bottomButtonBar?.selectButton ?? self.view + } + + shareViewController.completionWithItemsHandler = { (_, _, _, _) in + // Completed + existingCompletionHandler?(share) + } + + presentingViewController.present(shareViewController, animated: true, completion: nil) + } + } else { + // Completed + existingCompletionHandler?(share) + } + } + + if UIDevice.current.isIpad { + // On iPad, first show Share Sheet, then close ShareViewController + handleResultAndShowShareSheet(share) + return // Avoid calling self.complete(with:), called via existingCompletionHandler + } else { + // On iPhone, first close ShareViewController, then show Share Sheet + self.completionHandler = handleResultAndShowShareSheet + } + } + self.complete(with: share) } }) diff --git a/ownCloudAppShared/Client/User Interface/BottomButtonBar.swift b/ownCloudAppShared/Client/User Interface/BottomButtonBar.swift index ab36d4cd5..11869b00a 100644 --- a/ownCloudAppShared/Client/User Interface/BottomButtonBar.swift +++ b/ownCloudAppShared/Client/User Interface/BottomButtonBar.swift @@ -21,6 +21,7 @@ import UIKit open class BottomButtonBar: ThemeCSSView { open var selectButton: UIButton = UIButton() open var cancelButton: UIButton = UIButton() + open var alternativeButton: UIButton = UIButton() open var promptLabel: UILabel = ThemeCSSLabel(withSelectors: [.label]) open var bottomSeparatorLine: UIView = ThemeCSSView(withSelectors: [.separator]) @@ -33,6 +34,15 @@ open class BottomButtonBar: ThemeCSSView { } } } + open var alternativeButtonTitle: String? { + didSet { + var buttonConfiguration = alternativeButton.configuration + if buttonConfiguration != nil { + buttonConfiguration?.title = alternativeButtonTitle + alternativeButton.configuration = buttonConfiguration + } + } + } open var cancelButtonTitle: String? { didSet { var buttonConfiguration = cancelButton.configuration @@ -45,6 +55,7 @@ open class BottomButtonBar: ThemeCSSView { open var promptText: String? open var hasCancelButton: Bool + open var hasAlternativeButton: Bool var activityIndicator: UIActivityIndicatorView? var showActivityIndicatorWhileModalActionRunning = true @@ -74,27 +85,32 @@ open class BottomButtonBar: ThemeCSSView { } cancelButton.isEnabled = !modalActionRunning + alternativeButton.isEnabled = !modalActionRunning selectButton.isEnabled = !modalActionRunning } } - public init(prompt: String? = nil, selectButtonTitle: String, cancelButtonTitle: String? = "Cancel".localized, hasCancelButton: Bool, selectAction: UIAction?, cancelAction: UIAction?) { + public init(prompt: String? = nil, selectButtonTitle: String, alternativeButtonTitle: String? = nil, cancelButtonTitle: String? = "Cancel".localized, hasAlternativeButton: Bool = false, hasCancelButton: Bool, selectAction: UIAction?, alternativeAction:UIAction? = nil, cancelAction: UIAction?) { self.selectButtonTitle = selectButtonTitle + self.hasAlternativeButton = hasAlternativeButton && (alternativeButtonTitle != nil) self.hasCancelButton = hasCancelButton super.init() cssSelector = .bottomButtonBar + self.alternativeButtonTitle = alternativeButtonTitle self.cancelButtonTitle = cancelButtonTitle translatesAutoresizingMaskIntoConstraints = false selectButton.translatesAutoresizingMaskIntoConstraints = false + alternativeButton.translatesAutoresizingMaskIntoConstraints = false cancelButton.translatesAutoresizingMaskIntoConstraints = false promptLabel.translatesAutoresizingMaskIntoConstraints = false bottomSeparatorLine.translatesAutoresizingMaskIntoConstraints = false selectButton.setContentCompressionResistancePriority(.required, for: .vertical) + alternativeButton.setContentCompressionResistancePriority(.required, for: .vertical) cancelButton.setContentCompressionResistancePriority(.required, for: .vertical) var selectButtonConfig = UIButton.Configuration.borderedProminent() @@ -105,9 +121,21 @@ open class BottomButtonBar: ThemeCSSView { selectButton.addAction(selectAction, for: .primaryActionTriggered) } + if hasAlternativeButton { + var alternativeButtonConfig = UIButton.Configuration.bordered() + alternativeButtonConfig.title = alternativeButtonTitle + alternativeButtonConfig.cornerStyle = .large + alternativeButton.configuration = alternativeButtonConfig + if let alternativeAction { + alternativeButton.addAction(alternativeAction, for: .primaryActionTriggered) + } + + addSubview(alternativeButton) + } + if hasCancelButton { var cancelButtonConfig = UIButton.Configuration.bordered() - cancelButtonConfig.title = "Cancel".localized + cancelButtonConfig.title = cancelButtonTitle ?? "Cancel".localized cancelButtonConfig.cornerStyle = .large cancelButton.configuration = cancelButtonConfig if let cancelAction { @@ -142,13 +170,17 @@ open class BottomButtonBar: ThemeCSSView { func updateLayout() { var constraints: [NSLayoutConstraint] = [] - let isHorizontalLayout = (traitCollection.horizontalSizeClass == .regular) || (promptText == nil) + let promptTextInLineWithButtons = (traitCollection.horizontalSizeClass == .regular) || (promptText == nil) - if isHorizontalLayout { - let leadingButtonAnchor = hasCancelButton ? cancelButton.leadingAnchor : selectButton.leadingAnchor + if promptTextInLineWithButtons { + // Place promptLabel in line with buttons: + // - with alternative button: (Cancel) Prompt .. (Alternative) [Select] + // - without alternative button: Prompt .. (Cancel) [Select] + let leadingButtonAnchor = hasAlternativeButton ? alternativeButton.leadingAnchor : (hasCancelButton ? cancelButton.leadingAnchor : selectButton.leadingAnchor) + let leadingPromptAnchor = hasAlternativeButton && hasCancelButton ? cancelButton.trailingAnchor : safeAreaLayoutGuide.leadingAnchor constraints.append(contentsOf: [ - promptLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 20), + promptLabel.leadingAnchor.constraint(equalTo: leadingPromptAnchor, constant: 20), promptLabel.trailingAnchor.constraint(lessThanOrEqualTo: leadingButtonAnchor, constant: -20), promptLabel.centerYAnchor.constraint(equalTo: selectButton.centerYAnchor), @@ -156,7 +188,24 @@ open class BottomButtonBar: ThemeCSSView { selectButton.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 20), selectButton.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -20) ]) + + if hasCancelButton { + if hasAlternativeButton { + // Place Cancel button to the left of the bar + constraints.append( + cancelButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 20) + ) + } else { + // Place Cancel button left of Select button + constraints.append( + cancelButton.trailingAnchor.constraint(equalTo: selectButton.leadingAnchor, constant: -15) + ) + } + } } else { + // Place promptLabel above buttons: + // [Prompt] + // [Cancel] ... (Alternative) [Select] constraints.append(contentsOf: [ promptLabel.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 20), promptLabel.trailingAnchor.constraint(lessThanOrEqualTo: safeAreaLayoutGuide.trailingAnchor, constant: -20), @@ -169,17 +218,25 @@ open class BottomButtonBar: ThemeCSSView { ]) if hasCancelButton { + // Place Cancel button to the left of the bar constraints.append( cancelButton.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor, constant: 20) ) } } + if hasAlternativeButton { + if hasAlternativeButton { + // Place Alternative button left of Select button and center it vertically + constraints.append(contentsOf: [ + alternativeButton.trailingAnchor.constraint(equalTo: selectButton.leadingAnchor, constant: -15), + alternativeButton.centerYAnchor.constraint(equalTo: selectButton.centerYAnchor) + ]) + } + } + if hasCancelButton { - constraints.append(contentsOf: [ - cancelButton.trailingAnchor.constraint(equalTo: selectButton.leadingAnchor, constant: -15), - cancelButton.centerYAnchor.constraint(equalTo: selectButton.centerYAnchor) - ]) + constraints.append(cancelButton.centerYAnchor.constraint(equalTo: selectButton.centerYAnchor)) } constraints.append(contentsOf: [ diff --git a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift index 9faf7c346..6083feaed 100644 --- a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift +++ b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift @@ -41,6 +41,7 @@ public class SegmentViewItem: NSObject { open var style: Style open var icon: UIImage? + open var iconRenderingMode: UIImage.RenderingMode? open var title: String? { didSet { _view = nil @@ -77,12 +78,13 @@ public class SegmentViewItem: NSObject { return _view } - public init(with icon: UIImage? = nil, title: String? = nil, style: Style = .plain, titleTextStyle: UIFont.TextStyle? = nil, titleTextWeight: UIFont.Weight? = nil, linebreakMode: NSLineBreakMode? = nil, lines: [Line]? = nil, view: UIView? = nil, representedObject: AnyObject? = nil, weakRepresentedObject: AnyObject? = nil, gestureRecognizers: [UIGestureRecognizer]? = nil) { + public init(with icon: UIImage? = nil, iconRenderingMode: UIImage.RenderingMode? = nil, title: String? = nil, style: Style = .plain, titleTextStyle: UIFont.TextStyle? = nil, titleTextWeight: UIFont.Weight? = nil, linebreakMode: NSLineBreakMode? = nil, lines: [Line]? = nil, view: UIView? = nil, representedObject: AnyObject? = nil, weakRepresentedObject: AnyObject? = nil, gestureRecognizers: [UIGestureRecognizer]? = nil) { self.style = style super.init() self.icon = icon + self.iconRenderingMode = iconRenderingMode self.title = title self.titleTextStyle = titleTextStyle self.titleTextWeight = titleTextWeight @@ -112,3 +114,22 @@ extension [SegmentViewItem] { }) } } + +extension SegmentViewItem { + public static func button(title: String, customizeButton: ((UIButton, UIButton.Configuration) -> UIButton.Configuration)? = nil, action: UIAction) -> SegmentViewItem { + var buttonConfig = UIButton.Configuration.plain() + buttonConfig.title = title + buttonConfig.contentInsets = .zero + + let button = ThemeCSSButton() + + if let customizeButton { + buttonConfig = customizeButton(button, buttonConfig) + } + + button.configuration = buttonConfig + button.addAction(action, for: .primaryActionTriggered) + + return SegmentViewItem(view: button) + } +} diff --git a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift index 084423c85..c80483baa 100644 --- a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift +++ b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift @@ -65,7 +65,7 @@ public class SegmentViewItemView: ThemeView, ThemeCSSAutoSelector { if let icon = item.icon { iconView = UIImageView() iconView?.cssSelector = .icon - iconView?.image = icon.withRenderingMode(.alwaysTemplate) + iconView?.image = icon.withRenderingMode(item.iconRenderingMode ?? .alwaysTemplate) iconView?.contentMode = .scaleAspectFit iconView?.translatesAutoresizingMaskIntoConstraints = false iconView?.setContentHuggingPriority(.required, for: .horizontal) diff --git a/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift b/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift index d61680635..bb450748e 100644 --- a/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift +++ b/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift @@ -327,6 +327,8 @@ public class ThemeCollection : NSObject { cellStateSet = ThemeColorStateSet.from(colorSet: cellSet, for: interfaceStyle) collectionBackgroundColor = darkBrandColor.darker(0.1) + cellSet.backgroundColor + groupedCellSet = ThemeColorSet.from(backgroundColor: darkBrandColor, tintColor: lightBrandColor, for: interfaceStyle) groupedCellStateSet = ThemeColorStateSet.from(colorSet: groupedCellSet, for: interfaceStyle) groupedCollectionBackgroundColor = useSystemColors ? .systemGroupedBackground.resolvedColor(with: styleTraitCollection) : navigationBarSet.backgroundColor.darker(0.3) @@ -505,12 +507,14 @@ public class ThemeCollection : NSObject { ThemeCSSRecord(selectors: [.collection, .selected, .selectionCheckmark], property: .stroke, value: UIColor.white), - ThemeCSSRecord(selectors: [.grouped, .collection], property: .fill, value: groupedCollectionBackgroundColor), - ThemeCSSRecord(selectors: [.insetGrouped, .collection], property: .fill, value: groupedCollectionBackgroundColor), - ThemeCSSRecord(selectors: [.grouped, .collection, .sectionHeader], property: .fill, value: groupedCollectionBackgroundColor), - ThemeCSSRecord(selectors: [.grouped, .collection, .cell, .action], property: .fill, value: cellStateSet.regular.backgroundColor), - ThemeCSSRecord(selectors: [.insetGrouped, .collection, .sectionHeader], property: .fill, value: groupedCollectionBackgroundColor), - ThemeCSSRecord(selectors: [.insetGrouped, .collection, .cell, .action], property: .fill, value: cellStateSet.regular.backgroundColor), + ThemeCSSRecord(selectors: [.grouped, .collection], property: .fill, value: groupedCollectionBackgroundColor), + ThemeCSSRecord(selectors: [.insetGrouped, .collection], property: .fill, value: groupedCollectionBackgroundColor), + ThemeCSSRecord(selectors: [.grouped, .collection, .sectionHeader], property: .fill, value: groupedCollectionBackgroundColor), + ThemeCSSRecord(selectors: [.grouped, .collection, .sectionHeader, .cell], property: .fill, value: groupedCollectionBackgroundColor), + ThemeCSSRecord(selectors: [.grouped, .collection, .cell], property: .fill, value: groupedCellStateSet.regular.backgroundColor), + ThemeCSSRecord(selectors: [.grouped, .collection, .cell, .action], property: .fill, value: cellStateSet.regular.backgroundColor), + ThemeCSSRecord(selectors: [.insetGrouped, .collection, .sectionHeader], property: .fill, value: groupedCollectionBackgroundColor), + ThemeCSSRecord(selectors: [.insetGrouped, .collection, .cell, .action], property: .fill, value: cellStateSet.regular.backgroundColor), // - Table View ThemeCSSRecord(selectors: [.table], property: .fill, value: cellStateSet.regular.backgroundColor),