From 1e4b24718b1362f816e039d518b41469a312127a Mon Sep 17 00:00:00 2001 From: kean Date: Tue, 11 Jun 2024 18:29:29 -0400 Subject: [PATCH 01/64] Fix an issue with content structure not always showing the latest info --- .../GutenbergViewController+MoreActions.swift | 28 ++++++++++++++----- .../Gutenberg/GutenbergViewController.swift | 17 +++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift index 6ff302bd346c..e6916e502da9 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController+MoreActions.swift @@ -84,18 +84,32 @@ extension GutenbergViewController { func makeMoreMenu() -> UIMenu { UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ - UIDeferredMenuElement.uncached { [weak self] in - $0(self?.makeMoreMenuSections() ?? []) + UIDeferredMenuElement.uncached { [weak self] callback in + // Common actions at the top so they are always in the same + // relative place. + callback(self?.makeMoreMenuMainSections() ?? []) + }, + UIDeferredMenuElement.uncached { [weak self] callback in + // Dynamic actions at the bottom. The actions are loaded asynchronously + // because they need the latest post content from the editor + // to display the correct state. + self?.requestHTML { + callback(self?.makeMoreMenuAsyncSections() ?? []) + } } ]) } - private func makeMoreMenuSections() -> [UIMenuElement] { - var sections: [UIMenuElement] = [ - // Common actions at the top so they are always in the same relative place + private func makeMoreMenuMainSections() -> [UIMenuElement] { + return [ UIMenu(title: "", subtitle: "", options: .displayInline, children: makeMoreMenuActions()), + ] + } + + private func makeMoreMenuAsyncSections() -> [UIMenuElement] { + var sections: [UIMenuElement] = [ // Dynamic actions at the bottom - UIMenu(title: "", subtitle: "", options: .displayInline, children: makeSecondaryActions()) + UIMenu(title: "", subtitle: "", options: .displayInline, children: makeMoreMenuSecondaryActions()) ] if let string = makeContextStructureString() { sections.append(UIAction(subtitle: string, attributes: [.disabled], handler: { _ in })) @@ -103,7 +117,7 @@ extension GutenbergViewController { return sections } - private func makeSecondaryActions() -> [UIAction] { + private func makeMoreMenuSecondaryActions() -> [UIAction] { var actions: [UIAction] = [] if post.original().isStatus(in: [.draft, .pending]) { actions.append(UIAction(title: Strings.saveDraft, image: UIImage(systemName: "doc"), attributes: (editorHasChanges && editorHasContent) ? [] : [.disabled]) { [weak self] _ in diff --git a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift index 39f57d603f23..ccf881c86284 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/GutenbergViewController.swift @@ -4,6 +4,7 @@ import Aztec import WordPressFlux import React import AutomatticTracks +import Combine class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelegate, PublishingEditor { let errorDomain: String = "GutenbergViewController.errorDomain" @@ -270,6 +271,10 @@ class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelega BlockEditorSettingsService(blog: post.blog, coreDataStack: ContextManager.sharedInstance()) }() + private let htmlDidChange = PassthroughSubject() + private var isRequestingHTML = false + private var cancellables: [AnyCancellable] = [] + // MARK: - Initializers required convenience init( post: AbstractPost, @@ -501,6 +506,16 @@ class GutenbergViewController: UIViewController, PostEditor, FeaturedImageDelega gutenberg.requestHTML() } + func requestHTML(_ completion: @escaping () -> Void) { + htmlDidChange.first() + .sink { completion() } + .store(in: &cancellables) + + guard !isRequestingHTML else { return } + isRequestingHTML = true + gutenberg.requestHTML() + } + func focusTitleIfNeeded() { guard !post.hasContent(), shouldPresentInformativeDialog == false, shouldPresentPhase2informativeDialog == false else { return @@ -823,6 +838,7 @@ extension GutenbergViewController: GutenbergBridgeDelegate { } func gutenbergDidProvideHTML(title: String, html: String, changed: Bool, contentInfo: ContentInfo?) { + isRequestingHTML = false if changed { self.html = html self.postTitle = title @@ -848,6 +864,7 @@ extension GutenbergViewController: GutenbergBridgeDelegate { break } } + htmlDidChange.send(()) } func gutenbergDidLayout() { From 7e1e49a761d655f777414643608c7b063ec93ff9 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 14 Jun 2024 17:11:18 -0400 Subject: [PATCH 02/64] Update release notes --- RELEASE-NOTES.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 5e2cd1d76fe1..2d78f25cc055 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -9,7 +9,7 @@ * [*] Fix an issue with a missing navigation bar background in Post Settings [#23334] * [*] Fix an issue where the app will sometimes save empty drafts [#23342] * [*] Add `.networkConnectionLost` to the list of "offline" scenarios so that the "offline changes" badge and fast retries work for this use case [#23348] - +* [*] Fix an issue with post content structure not loading in some scenarios [#23347] 25.0 ----- From 3107acc1c1e05e94571facfd3d7d45e22f6e0211 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 13:43:10 -0400 Subject: [PATCH 03/64] Add defensive code for PostCategory save --- .../Classes/Services/PostHelper+Metadata.swift | 18 ++++++++++++++++++ WordPress/Classes/Services/PostHelper.m | 11 +++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/WordPress/Classes/Services/PostHelper+Metadata.swift b/WordPress/Classes/Services/PostHelper+Metadata.swift index b9b80840035a..f9a9846c3d8d 100644 --- a/WordPress/Classes/Services/PostHelper+Metadata.swift +++ b/WordPress/Classes/Services/PostHelper+Metadata.swift @@ -11,4 +11,22 @@ extension PostHelper { value: dictionary["value"] as? String ) } + + @objc(createOrUpdateCategoryForRemoteCategory:blog:context:) + class func createOrUpdateCategory(for remoteCategory: RemotePostCategory, in blog: Blog, in context: NSManagedObjectContext) -> PostCategory? { + guard let categoryID = remoteCategory.categoryID else { + wpAssertionFailure("remote category missing categoryID") + return nil + } + if let category = try? PostCategory.lookup(withBlogID: blog.objectID, categoryID: categoryID, in: context) { + return category + } + let category = PostCategory(context: context) + // - warning: these PostCategory fields are explicitly unwrapped optionals! + category.blog = blog + category.categoryID = categoryID + category.categoryName = remoteCategory.name ?? "" + category.parentID = remoteCategory.parentID ?? 0 // `0` means "no parent" + return category + } } diff --git a/WordPress/Classes/Services/PostHelper.m b/WordPress/Classes/Services/PostHelper.m index b48cccd2a37e..3ae17022ab97 100644 --- a/WordPress/Classes/Services/PostHelper.m +++ b/WordPress/Classes/Services/PostHelper.m @@ -104,18 +104,13 @@ + (void)updatePost:(AbstractPost *)post withRemotePost:(RemotePost *)remotePost } + (void)updatePost:(Post *)post withRemoteCategories:(NSArray *)remoteCategories inContext:(NSManagedObjectContext *)managedObjectContext { - NSManagedObjectID *blogObjectID = post.blog.objectID; NSMutableSet *categories = [post mutableSetValueForKey:@"categories"]; [categories removeAllObjects]; for (RemotePostCategory *remoteCategory in remoteCategories) { - PostCategory *category = [PostCategory lookupWithBlogObjectID:blogObjectID categoryID:remoteCategory.categoryID inContext:managedObjectContext]; - if (!category) { - category = [PostCategory createWithBlogObjectID:blogObjectID inContext:managedObjectContext]; - category.categoryID = remoteCategory.categoryID; - category.categoryName = remoteCategory.name; - category.parentID = remoteCategory.parentID; + PostCategory *category = [PostHelper createOrUpdateCategoryForRemoteCategory:remoteCategory blog:post.blog context:managedObjectContext]; + if (category) { + [categories addObject:category]; } - [categories addObject:category]; } } From 1a83a7257690e90db7c7298bf1d907c095f10e58 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 17 Jun 2024 08:56:34 -0400 Subject: [PATCH 04/64] Update releasea notes --- RELEASE-NOTES.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2d78f25cc055..298663edd928 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -10,6 +10,8 @@ * [*] Fix an issue where the app will sometimes save empty drafts [#23342] * [*] Add `.networkConnectionLost` to the list of "offline" scenarios so that the "offline changes" badge and fast retries work for this use case [#23348] * [*] Fix an issue with post content structure not loading in some scenarios [#23347] +* [*] Fix a rare crash when updating posts with newly added categories [#23354] + 25.0 ----- From 963506d162476ed1d3617a74516fb082fc9f6a49 Mon Sep 17 00:00:00 2001 From: David Christiandy <1299411+dvdchr@users.noreply.github.com> Date: Mon, 17 Jun 2024 21:38:13 +0800 Subject: [PATCH 05/64] Fix spacing --- .../ViewRelated/Reader/Tab Navigation/ReaderTabView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift index 4c0f1f308fae..d7a7246cab95 100644 --- a/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Tab Navigation/ReaderTabView.swift @@ -109,7 +109,7 @@ extension ReaderTabView { private func addContentToContainerView(index: Int) { guard let controller = self.next as? UIViewController, - let childController = viewModel.makeChildContentViewController(at: index) else { + let childController = viewModel.makeChildContentViewController(at: index) else { return } From 4047bbdd7a173b8e1592e4a31cb10035e388a79a Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 16:14:20 -0400 Subject: [PATCH 06/64] Remove PostCoordinator.SavingError.unknown --- WordPress/Classes/Services/PostCoordinator.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 8a28c3a9b0a2..eeaa24891fe3 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -14,7 +14,6 @@ class PostCoordinator: NSObject { enum SavingError: Error, LocalizedError, CustomNSError { case mediaFailure(AbstractPost, Error) - case unknown var errorDescription: String? { Strings.genericErrorTitle @@ -24,8 +23,6 @@ class PostCoordinator: NSObject { switch self { case .mediaFailure(_, let error): return [NSUnderlyingErrorKey: error] - case .unknown: - return [:] } } } From f4885c576cdc27fba741f9be9e7ec803bcab9711 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 18:48:29 -0400 Subject: [PATCH 07/64] Add SyncWorker.maximumRetryTimeInterval and Retry button to post context menu --- .../Classes/Services/PostCoordinator.swift | 44 ++++++++++++++++++- .../Post/AbstractPostListViewController.swift | 4 ++ .../Post/AbstractPostMenuHelper.swift | 16 ++++++- .../Post/AbstractPostMenuViewModel.swift | 5 ++- .../Post/InteractivePostViewDelegate.swift | 1 + .../ViewRelated/Post/PageMenuViewModel.swift | 11 ++++- .../Post/PostCardStatusViewModel.swift | 11 ++++- 7 files changed, 86 insertions(+), 6 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index eeaa24891fe3..9717245b1a2c 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -13,7 +13,10 @@ protocol PostCoordinatorDelegate: AnyObject { class PostCoordinator: NSObject { enum SavingError: Error, LocalizedError, CustomNSError { + /// One of the media uploads failed. case mediaFailure(AbstractPost, Error) + /// The upload has been failing for too long. + case maximumRetryTimeIntervalReached var errorDescription: String? { Strings.genericErrorTitle @@ -23,6 +26,8 @@ class PostCoordinator: NSObject { switch self { case .mediaFailure(_, let error): return [NSUnderlyingErrorKey: error] + case .maximumRetryTimeIntervalReached: + return [:] } } } @@ -290,6 +295,19 @@ class PostCoordinator: NSObject { startSync(for: revision.original()) } + func retrySync(for post: AbstractPost) { + wpAssert(post.isOriginal()) + + guard let revision = post.getLatestRevisionNeedingSync() else { + return + } + revision.confirmedChangesTimestamp = Date() + ContextManager.shared.saveContextAndWait(coreDataStack.mainContext) + + getWorker(for: post).showNextError = true + startSync(for: post) + } + /// Schedules sync for all the posts with revisions that need syncing. /// /// - note: It should typically only be called once during the app launch. @@ -351,6 +369,10 @@ class PostCoordinator: NSObject { /// A manages sync for the given post. Every post has its own worker. private final class SyncWorker { + /// Defines for how many days (in seconds) the app should continue trying + /// to upload the post before giving up and requiring manual intervention. + static let maximumRetryTimeInterval: TimeInterval = 86400 * 3 // 3 days + let post: AbstractPost var isPaused = false var operation: SyncOperation? // The sync operation that's currently running @@ -365,6 +387,7 @@ class PostCoordinator: NSObject { } var retryDelay: TimeInterval weak var retryTimer: Timer? + var showNextError = false deinit { self.log("deinit") @@ -437,6 +460,13 @@ class PostCoordinator: NSObject { let worker = getWorker(for: post) + if let date = revision.confirmedChangesTimestamp, + Date.now.timeIntervalSince(date) > SyncWorker.maximumRetryTimeInterval { + worker.error = PostCoordinator.SavingError.maximumRetryTimeIntervalReached + postDidUpdateNotification(for: post) + return worker.log("stopping – failing to upload changes for too long") + } + guard !worker.isPaused else { return worker.log("start failed: worker is paused") } @@ -526,6 +556,11 @@ class PostCoordinator: NSObject { worker.setLongerDelay() } + if worker.showNextError { + worker.showNextError = false + handleError(error, for: operation.post) + } + let delay = worker.nextRetryDelay worker.retryTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self, weak worker] _ in guard let self, let worker else { return } @@ -550,8 +585,13 @@ class PostCoordinator: NSObject { return true } } - if let error = error as? SavingError, case .mediaFailure(_, let error) = error { - return MediaCoordinator.isTerminalError(error) + if let error = error as? SavingError { + switch error { + case .mediaFailure(_, let error): + return MediaCoordinator.isTerminalError(error) + case .maximumRetryTimeIntervalReached: + return true + } } return false } diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift index beae2b7c30bf..c44184b24000 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift @@ -716,6 +716,10 @@ class AbstractPostListViewController: UIViewController, alert.presentFromRootViewController() } + func retry(_ post: AbstractPost) { + PostCoordinator.shared.retrySync(for: post.original()) + } + func stats(for post: AbstractPost) { viewStatsForPost(post) } diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift index 60eff91558c4..f53a664ae87e 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift @@ -47,7 +47,7 @@ struct AbstractPostMenuHelper { .filter { !$0.buttons.isEmpty } .map { section in let actions = makeActions(for: section.buttons, presentingView: presentingView, delegate: delegate) - let menu = UIMenu(title: "", options: .displayInline, children: actions) + let menu = UIMenu(title: section.title ?? "", subtitle: "", options: .displayInline, children: actions) if let submenuButton = section.submenuButton { return UIMenu( @@ -99,6 +99,7 @@ extension AbstractPostButton: AbstractPostMenuAction { case .moveToDraft: return UIImage(systemName: "pencil.line") case .trash: return UIImage(systemName: "trash") case .delete: return UIImage(systemName: "trash") + case .retry: return UIImage(systemName: "arrow.triangle.2.circlepath") case .share: return UIImage(systemName: "square.and.arrow.up") case .blaze: return UIImage(systemName: "flame") case .comments: return UIImage(systemName: "bubble.right") @@ -128,6 +129,7 @@ extension AbstractPostButton: AbstractPostMenuAction { case .moveToDraft: return Strings.draft case .trash: return Strings.trash case .delete: return Strings.delete + case .retry: return Strings.retry case .share: return Strings.share case .blaze: return Strings.blaze case .comments: return Strings.comments @@ -139,6 +141,14 @@ extension AbstractPostButton: AbstractPostMenuAction { } } + func subtitle(for post: AbstractPost) -> String? { + if self == .retry { + let error = PostCoordinator.shared.syncError(for: post.original()) + return error?.localizedDescription + } + return nil + } + func performAction(for post: AbstractPost, view: UIView, delegate: InteractivePostViewDelegate) { switch self { case .view: @@ -155,6 +165,8 @@ extension AbstractPostButton: AbstractPostMenuAction { delegate.trash(post) case .delete: delegate.delete(post) + case .retry: + delegate.retry(post) case .share: delegate.share(post, fromView: view) case .blaze: @@ -182,6 +194,8 @@ extension AbstractPostButton: AbstractPostMenuAction { static let draft = NSLocalizedString("posts.draft.actionTitle", value: "Move to draft", comment: "Label for an option that moves a post to the draft folder") static let delete = NSLocalizedString("posts.delete.actionTitle", value: "Delete permanently", comment: "Label for the delete post option. Tapping permanently deletes a post.") static let trash = NSLocalizedString("posts.trash.actionTitle", value: "Move to trash", comment: "Label for a option that moves a post to the trash folder") + // TODO: Replace with a namespaced string + static let retry = NSLocalizedString("Retry", comment: "User action to retry media upload.") static let view = NSLocalizedString("posts.view.actionTitle", value: "View", comment: "Label for the view post button. Tapping displays the post as it appears on the web.") static let preview = NSLocalizedString("posts.preview.actionTitle", value: "Preview", comment: "Label for the preview post button. Tapping displays the post as it appears on the web.") static let publish = NSLocalizedString("posts.publish.actionTitle", value: "Publish", comment: "Label for the publish post button.") diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuViewModel.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuViewModel.swift index f95ac7698c81..db9e5cf95763 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuViewModel.swift @@ -5,10 +5,12 @@ protocol AbstractPostMenuViewModel { } struct AbstractPostButtonSection { + let title: String? let buttons: [AbstractPostButton] let submenuButton: AbstractPostButton? - init(buttons: [AbstractPostButton], submenuButton: AbstractPostButton? = nil) { + init(title: String? = nil, buttons: [AbstractPostButton], submenuButton: AbstractPostButton? = nil) { + self.title = title self.buttons = buttons self.submenuButton = submenuButton } @@ -22,6 +24,7 @@ enum AbstractPostButton: Equatable { case moveToDraft case trash case delete + case retry case share case blaze case comments diff --git a/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift b/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift index 262a34107ea1..8066b544b434 100644 --- a/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift +++ b/WordPress/Classes/ViewRelated/Post/InteractivePostViewDelegate.swift @@ -9,6 +9,7 @@ protocol InteractivePostViewDelegate: AnyObject { func trash(_ post: AbstractPost, completion: @escaping () -> Void) func delete(_ post: AbstractPost, completion: @escaping () -> Void) func draft(_ post: AbstractPost) + func retry(_ post: AbstractPost) func share(_ post: AbstractPost, fromView view: UIView) func blaze(_ post: AbstractPost) func comments(_ post: AbstractPost) diff --git a/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift b/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift index 62bb1ee1e2f5..fb86ef6bd900 100644 --- a/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift @@ -15,7 +15,8 @@ final class PageMenuViewModel: AbstractPostMenuViewModel { createBlazeSection(), createSetPageAttributesSection(), createNavigationSection(), - createTrashSection() + createTrashSection(), + createUploadStatusSection() ] } @@ -125,4 +126,12 @@ final class PageMenuViewModel: AbstractPostMenuViewModel { let action: AbstractPostButton = page.original().status == .trash ? .delete : .trash return AbstractPostButtonSection(buttons: [action]) } + + private func createUploadStatusSection() -> AbstractPostButtonSection { + guard let error = PostCoordinator.shared.syncError(for: page.original()), + PostCoordinator.isTerminalError(error) else { + return AbstractPostButtonSection(buttons: []) + } + return AbstractPostButtonSection(title: error.localizedDescription, buttons: [.retry]) + } } diff --git a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift index 657ed13c22a0..f173836c5582 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift @@ -60,7 +60,8 @@ class PostCardStatusViewModel: NSObject, AbstractPostMenuViewModel { createSecondarySection(), createBlazeSection(), createNavigationSection(), - createTrashSection() + createTrashSection(), + createUploadStatusSection() ] } @@ -122,6 +123,14 @@ class PostCardStatusViewModel: NSObject, AbstractPostMenuViewModel { return AbstractPostButtonSection(buttons: [action]) } + private func createUploadStatusSection() -> AbstractPostButtonSection { + guard let error = PostCoordinator.shared.syncError(for: post.original()), + PostCoordinator.isTerminalError(error) else { + return AbstractPostButtonSection(buttons: []) + } + return AbstractPostButtonSection(title: error.localizedDescription, buttons: [.retry]) + } + private var canPublish: Bool { let userCanPublish = post.blog.capabilities != nil ? post.blog.isPublishingPostsAllowed() : true return (post.status == .draft || post.status == .pending) && userCanPublish From 1ca65cf2b2cd523db0ddf08e418ebf3aad2625e7 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 19:00:42 -0400 Subject: [PATCH 08/64] Show post title in Resolve Conflict view --- .../Classes/ViewRelated/Post/ResolveConflictView.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Post/ResolveConflictView.swift b/WordPress/Classes/ViewRelated/Post/ResolveConflictView.swift index 920b1eebaabe..14831b80979c 100644 --- a/WordPress/Classes/ViewRelated/Post/ResolveConflictView.swift +++ b/WordPress/Classes/ViewRelated/Post/ResolveConflictView.swift @@ -23,7 +23,14 @@ struct ResolveConflictView: View { var body: some View { Form { Section { - Text(Strings.description) + VStack(alignment: .leading, spacing: 12) { + if let title = post.latest().titleForDisplay() { + Text("\"\(title)\"") + .font(.headline) + .lineLimit(2) + } + Text(Strings.description) + } ForEach(versions) { version in PostVersionView(version: version, selectedVersion: $selectedVersion) } From f7ba52cae002e4aede6cf6829cf3eaedcdc0c076 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 19:18:29 -0400 Subject: [PATCH 09/64] Show error more eagerly --- WordPress/Classes/Services/PostCoordinator.swift | 11 ++++++++--- .../Classes/ViewRelated/Post/PageMenuViewModel.swift | 3 +-- .../ViewRelated/Post/PostCardStatusViewModel.swift | 3 +-- .../ViewRelated/Post/PostSyncStateViewModel.swift | 5 ++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 9717245b1a2c..f4810fe48d21 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -379,12 +379,14 @@ class PostCoordinator: NSObject { var error: Error? // The previous sync error var nextRetryDelay: TimeInterval { - retryDelay = min(60, retryDelay * 2) + retryDelay = min(90, retryDelay * 2) return retryDelay } + func setLongerDelay() { - retryDelay = max(retryDelay, 30) + retryDelay = max(retryDelay, 45) } + var retryDelay: TimeInterval weak var retryTimer: Timer? var showNextError = false @@ -578,7 +580,10 @@ class PostCoordinator: NSObject { /// Returns `true` if the error can't be resolved by simply retrying and /// requires user interventions, for example, resolving a conflict. - static func isTerminalError(_ error: Error) -> Bool { + private static func isTerminalError(_ error: Error) -> Bool { + if error is WordPressKit.WordPressComRestApiEndpointError { + return true + } if let saveError = error as? PostRepository.PostSaveError { switch saveError { case .deleted, .conflict: diff --git a/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift b/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift index fb86ef6bd900..f14bb43c62fc 100644 --- a/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PageMenuViewModel.swift @@ -128,8 +128,7 @@ final class PageMenuViewModel: AbstractPostMenuViewModel { } private func createUploadStatusSection() -> AbstractPostButtonSection { - guard let error = PostCoordinator.shared.syncError(for: page.original()), - PostCoordinator.isTerminalError(error) else { + guard let error = PostCoordinator.shared.syncError(for: page.original()) else { return AbstractPostButtonSection(buttons: []) } return AbstractPostButtonSection(title: error.localizedDescription, buttons: [.retry]) diff --git a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift index f173836c5582..180f4be345c0 100644 --- a/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostCardStatusViewModel.swift @@ -124,8 +124,7 @@ class PostCardStatusViewModel: NSObject, AbstractPostMenuViewModel { } private func createUploadStatusSection() -> AbstractPostButtonSection { - guard let error = PostCoordinator.shared.syncError(for: post.original()), - PostCoordinator.isTerminalError(error) else { + guard let error = PostCoordinator.shared.syncError(for: post.original()) else { return AbstractPostButtonSection(buttons: []) } return AbstractPostButtonSection(title: error.localizedDescription, buttons: [.retry]) diff --git a/WordPress/Classes/ViewRelated/Post/PostSyncStateViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSyncStateViewModel.swift index 2a340265007c..2f2b6375aad7 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSyncStateViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSyncStateViewModel.swift @@ -22,12 +22,11 @@ final class PostSyncStateViewModel { return .uploading } if let error = PostCoordinator.shared.syncError(for: post.original()) { - if PostCoordinator.isTerminalError(error) { - return .failed - } if let urlError = (error as NSError).underlyingErrors.first as? URLError, urlError.code == .notConnectedToInternet || urlError.code == .networkConnectionLost { return .offlineChanges // A better indicator on what's going on + } else { + return .failed } } if PostCoordinator.shared.isSyncNeeded(for: post) { From 3ceeef1310ebd36c2d65c662158aab5f7b3c76da Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 19:28:48 -0400 Subject: [PATCH 10/64] Remove the concept of terminal errors --- .../Classes/Services/PostCoordinator.swift | 37 ++----------------- 1 file changed, 3 insertions(+), 34 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index f4810fe48d21..8d8a739309b1 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -54,8 +54,8 @@ class PostCoordinator: NSObject { private let mediaCoordinator: MediaCoordinator private let actionDispatcherFacade: ActionDispatcherFacade - /// The initial sync retry delay. By default, 8 seconds. - var syncRetryDelay: TimeInterval = 8 + /// The initial sync retry delay. By default, 20 seconds. + var syncRetryDelay: TimeInterval = 20 // MARK: - Initializers @@ -379,14 +379,10 @@ class PostCoordinator: NSObject { var error: Error? // The previous sync error var nextRetryDelay: TimeInterval { - retryDelay = min(90, retryDelay * 2) + retryDelay = min(120, retryDelay * 2) return retryDelay } - func setLongerDelay() { - retryDelay = max(retryDelay, 45) - } - var retryDelay: TimeInterval weak var retryTimer: Timer? var showNextError = false @@ -554,10 +550,6 @@ class PostCoordinator: NSObject { worker.error = error postDidUpdateNotification(for: operation.post) - if PostCoordinator.isTerminalError(error) { - worker.setLongerDelay() - } - if worker.showNextError { worker.showNextError = false handleError(error, for: operation.post) @@ -578,29 +570,6 @@ class PostCoordinator: NSObject { } } - /// Returns `true` if the error can't be resolved by simply retrying and - /// requires user interventions, for example, resolving a conflict. - private static func isTerminalError(_ error: Error) -> Bool { - if error is WordPressKit.WordPressComRestApiEndpointError { - return true - } - if let saveError = error as? PostRepository.PostSaveError { - switch saveError { - case .deleted, .conflict: - return true - } - } - if let error = error as? SavingError { - switch error { - case .mediaFailure(_, let error): - return MediaCoordinator.isTerminalError(error) - case .maximumRetryTimeIntervalReached: - return true - } - } - return false - } - private func didRetryTimerFire(for worker: SyncWorker) { worker.log("retry timer fired") startSync(for: worker.post) From 5aa53c19aaee7e8ed4270293e5d2434940966a90 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 19:45:19 -0400 Subject: [PATCH 11/64] Reduce the default delay to 15 seconds --- WordPress/Classes/Services/PostCoordinator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPress/Classes/Services/PostCoordinator.swift b/WordPress/Classes/Services/PostCoordinator.swift index 8d8a739309b1..0039fc2c91cd 100644 --- a/WordPress/Classes/Services/PostCoordinator.swift +++ b/WordPress/Classes/Services/PostCoordinator.swift @@ -54,8 +54,8 @@ class PostCoordinator: NSObject { private let mediaCoordinator: MediaCoordinator private let actionDispatcherFacade: ActionDispatcherFacade - /// The initial sync retry delay. By default, 20 seconds. - var syncRetryDelay: TimeInterval = 20 + /// The initial sync retry delay. By default, 15 seconds. + var syncRetryDelay: TimeInterval = 15 // MARK: - Initializers From 994e83ed29501b7986db90cb08ee8397d4f9b400 Mon Sep 17 00:00:00 2001 From: kean Date: Thu, 13 Jun 2024 19:46:09 -0400 Subject: [PATCH 12/64] Remove unused code --- .../Classes/ViewRelated/Post/AbstractPostMenuHelper.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift index f53a664ae87e..43f7d5f69cb8 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostMenuHelper.swift @@ -141,14 +141,6 @@ extension AbstractPostButton: AbstractPostMenuAction { } } - func subtitle(for post: AbstractPost) -> String? { - if self == .retry { - let error = PostCoordinator.shared.syncError(for: post.original()) - return error?.localizedDescription - } - return nil - } - func performAction(for post: AbstractPost, view: UIView, delegate: InteractivePostViewDelegate) { switch self { case .view: From c853e2eb15fe1f154980135b43329b1ff2aae2c1 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 14 Jun 2024 08:22:24 -0400 Subject: [PATCH 13/64] Add missing analytics --- .../Classes/Utility/Analytics/WPAnalyticsEvent.swift | 10 ++++++++++ .../Post/AbstractPostListViewController.swift | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 2cb98084345e..6808517bbf58 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -305,6 +305,8 @@ import Foundation case postListSetHomePageAction case postListSetAsRegularPageAction case postListSettingsAction + case postListDeleteAction + case postListRetryAction // Page List case pageListEditHomepageTapped @@ -1166,6 +1168,10 @@ import Foundation return "post_list_button_pressed" case .postListSettingsAction: return "post_list_button_pressed" + case .postListDeleteAction: + return "post_list_button_pressed" + case .postListRetryAction: + return "post_list_button_pressed" // Page List case .pageListEditHomepageTapped: @@ -1741,6 +1747,10 @@ import Foundation return ["button": "set_regular_page"] case .postListSettingsAction: return ["button": "settings"] + case .postListDeleteAction: + return ["button": "delete"] + case .postListRetryAction: + return ["button": "retry"] default: return nil } diff --git a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift index c44184b24000..b18235d0858b 100644 --- a/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/AbstractPostListViewController.swift @@ -677,6 +677,8 @@ class AbstractPostListViewController: UIViewController, } func trash(_ post: AbstractPost, completion: @escaping () -> Void) { + WPAnalytics.track(.postListTrashAction, withProperties: propertiesForAnalytics()) + let post = post.original() func performAction() { @@ -701,6 +703,8 @@ class AbstractPostListViewController: UIViewController, } func delete(_ post: AbstractPost, completion: @escaping () -> Void) { + WPAnalytics.track(.postListDeleteAction, properties: propertiesForAnalytics()) + let post = post.original() let alert = UIAlertController(title: Strings.Delete.actionTitle, message: Strings.Delete.message(for: post.latest()), preferredStyle: .alert) @@ -717,6 +721,7 @@ class AbstractPostListViewController: UIViewController, } func retry(_ post: AbstractPost) { + WPAnalytics.track(.postListRetryAction, properties: propertiesForAnalytics()) PostCoordinator.shared.retrySync(for: post.original()) } From df2a1db16ff3a317e42eebc326c060919f6c142b Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 17 Jun 2024 10:30:26 -0400 Subject: [PATCH 14/64] Update releaes notes --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 2d78f25cc055..96e708866f73 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -9,6 +9,7 @@ * [*] Fix an issue with a missing navigation bar background in Post Settings [#23334] * [*] Fix an issue where the app will sometimes save empty drafts [#23342] * [*] Add `.networkConnectionLost` to the list of "offline" scenarios so that the "offline changes" badge and fast retries work for this use case [#23348] +* [*] If draft sync fails, the app will now show the "Retry" button in the context menu along with the error message [#23355] * [*] Fix an issue with post content structure not loading in some scenarios [#23347] 25.0 From e406130bab84b79d57eff8dd6af3972c428c87c0 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 17 Jun 2024 14:47:34 -0400 Subject: [PATCH 15/64] Add defensive code in PostCategoryService --- RELEASE-NOTES.txt | 2 +- .../Classes/Services/PostCategoryService.m | 20 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 298663edd928..0c0b7580b0b7 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -10,7 +10,7 @@ * [*] Fix an issue where the app will sometimes save empty drafts [#23342] * [*] Add `.networkConnectionLost` to the list of "offline" scenarios so that the "offline changes" badge and fast retries work for this use case [#23348] * [*] Fix an issue with post content structure not loading in some scenarios [#23347] -* [*] Fix a rare crash when updating posts with newly added categories [#23354] +* [*] Fix a rare crash when updating posts with categories [#23354] 25.0 diff --git a/WordPress/Classes/Services/PostCategoryService.m b/WordPress/Classes/Services/PostCategoryService.m index a5ebe7158ddc..c3589d1ce08c 100644 --- a/WordPress/Classes/Services/PostCategoryService.m +++ b/WordPress/Classes/Services/PostCategoryService.m @@ -165,15 +165,19 @@ - (void)mergeCategories:(NSArray *)remoteCategories forBl NSMutableArray *categories = [NSMutableArray arrayWithCapacity:remoteCategories.count]; for (RemotePostCategory *remoteCategory in remoteCategories) { - PostCategory *category = [PostCategory lookupWithBlogObjectID:blog.objectID categoryID:remoteCategory.categoryID inContext:context]; - if (!category) { - category = [PostCategory createWithBlogObjectID:blog.objectID inContext:context]; - category.categoryID = remoteCategory.categoryID; - } - category.categoryName = remoteCategory.name; - category.parentID = remoteCategory.parentID; + if (remoteCategory.categoryID) { + PostCategory *category = [PostCategory lookupWithBlogObjectID:blog.objectID categoryID:remoteCategory.categoryID inContext:context]; + if (!category) { + category = [PostCategory createWithBlogObjectID:blog.objectID inContext:context]; + category.categoryID = remoteCategory.categoryID; + } + category.categoryName = remoteCategory.name; + if (remoteCategory.parentID) { + category.parentID = remoteCategory.parentID; + } - [categories addObject:category]; + [categories addObject:category]; + } } } From 2500aa51c90dad3eea58b4ef131f92d81673ba11 Mon Sep 17 00:00:00 2001 From: kean Date: Fri, 14 Jun 2024 11:48:25 -0400 Subject: [PATCH 16/64] Fix an issue where you could remove the scheduled date on a scheduled post --- .../Post/Scheduling/PublishSettingsViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift index a6de012fdf61..b68be38f73d8 100644 --- a/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Scheduling/PublishSettingsViewController.swift @@ -33,7 +33,7 @@ struct PublishSettingsViewModel { private let post: AbstractPost - var isRequired: Bool { (post.original ?? post).status == .publish } + var isRequired: Bool { post.original().isStatus(in: [.publish, .scheduled]) } let dateFormatter: DateFormatter let dateTimeFormatter: DateFormatter From cb7fc2aba0fd13deee8f4504d0992817564cb8d4 Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 17 Jun 2024 17:07:30 -0400 Subject: [PATCH 17/64] Update release notes --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 27ead1cebd03..87316a97cbec 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -12,6 +12,7 @@ * [*] If draft sync fails, the app will now show the "Retry" button in the context menu along with the error message [#23355] * [*] Fix an issue with post content structure not loading in some scenarios [#23347] * [*] Fix a rare crash when updating posts with categories [#23354] +* [*] Fix an issue where you could remove the scheduled date on a scheduled post [#23360] 25.0 From 20a14bdcf00b53e678ee2265f311fd69318b53f4 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Tue, 18 Jun 2024 09:50:47 +1200 Subject: [PATCH 18/64] Move WordPressKit and WordPressAuthenticator source code --- .../AuthenticatorAnalyticsTracker.swift | 512 ++ .../WordPressAuthenticator+Errors.swift | 15 + .../WordPressAuthenticator+Events.swift | 36 + ...WordPressAuthenticator+Notifications.swift | 19 + .../WordPressAuthenticator.swift | 567 ++ .../WordPressAuthenticatorConfiguration.swift | 258 + ...rdPressAuthenticatorDelegateProtocol.swift | 199 + .../WordPressAuthenticatorDisplayImages.swift | 22 + ...WordPressAuthenticatorDisplayStrings.swift | 259 + .../WordPressAuthenticatorResult.swift | 27 + .../WordPressAuthenticatorStyles.swift | 305 + .../WordPressSupportSourceTag.swift | 84 + .../AuthenticatorCredentials.swift | 20 + .../Credentials/WordPressComCredentials.swift | 42 + .../Credentials/WordPressOrgCredentials.swift | 45 + .../Email Client Picker/AppSelector.swift | 134 + .../LinkMailPresenter.swift | 48 + .../Email Client Picker/URLHandler.swift | 12 + .../FancyAlertViewController+LoginError.swift | 218 + .../Sources/Extensions/NSObject+Helpers.swift | 12 + .../Sources/Extensions/String+Underline.swift | 24 + .../Sources/Extensions/UIButton+Styles.swift | 15 + .../Sources/Extensions/UIImage+Assets.swift | 51 + .../Extensions/UIPasteboard+Detect.swift | 77 + .../Extensions/UIStoryboard+Helpers.swift | 30 + .../Extensions/UITableView+Helpers.swift | 20 + .../Extensions/UIView+AuthHelpers.swift | 18 + .../UIViewController+Dismissal.swift | 22 + .../Extensions/UIViewController+Helpers.swift | 11 + .../Extensions/URL+JetpackConnect.swift | 7 + .../Extensions/WPStyleGuide+Login.swift | 350 + ...ebAuthenticationSession+Utils.swift .swift | 20 + .../GoogleSignIn/Character+URLSafe.swift | 10 + .../Sources/GoogleSignIn/Data+Base64URL.swift | 34 + .../Sources/GoogleSignIn/Data+SHA256.swift | 12 + .../Sources/GoogleSignIn/DataGetting.swift | 4 + .../Sources/GoogleSignIn/GoogleClientId.swift | 26 + .../GoogleSignIn/GoogleOAuthTokenGetter.swift | 28 + .../GoogleOAuthTokenGetting.swift | 9 + .../Sources/GoogleSignIn/IDToken.swift | 23 + .../Sources/GoogleSignIn/JSONWebToken.swift | 47 + .../GoogleSignIn/NewGoogleAuthenticator.swift | 123 + .../Sources/GoogleSignIn/OAuthError.swift | 24 + .../OAuthRequestBody+GoogleSignIn.swift | 26 + .../GoogleSignIn/OAuthTokenRequestBody.swift | 45 + .../GoogleSignIn/OAuthTokenResponseBody.swift | 35 + .../ProofKeyForCodeExchange.swift | 120 + .../GoogleSignIn/Result+ConvenienceInit.swift | 15 + .../GoogleSignIn/URL+GoogleSignIn.swift | 39 + .../URLRequest+GoogleSignIn.swift | 18 + .../GoogleSignIn/URLSesison+DataGetting.swift | 20 + WordPressAuthenticator/Sources/Info.plist | 26 + .../Sources/Logging/WPAuthenticatorLogging.h | 22 + .../Sources/Logging/WPAuthenticatorLogging.m | 49 + .../Logging/WPAuthenticatorLogging.swift | 19 + .../Model/LoginFields+Validation.swift | 47 + .../Sources/Model/LoginFields.swift | 143 + .../Sources/Model/LoginFieldsMeta.swift | 83 + .../Sources/Model/WordPressComSiteInfo.swift | 61 + .../Sources/NUX/Button/NUXButton.swift | 267 + .../NUX/Button/NUXButtonView.storyboard | 206 + .../NUX/Button/NUXButtonViewController.swift | 310 + .../NUXStackedButtonsViewController.swift | 262 + .../NUX/ModalViewControllerPresenting.swift | 5 + .../Sources/NUX/NUXKeyboardResponder.swift | 148 + .../NUX/NUXLinkAuthViewController.swift | 44 + .../NUX/NUXLinkMailViewController.swift | 128 + .../Sources/NUX/NUXNavigationController.swift | 9 + .../Sources/NUX/NUXTableViewController.swift | 46 + .../Sources/NUX/NUXViewController.swift | 86 + .../Sources/NUX/NUXViewControllerBase.swift | 333 + .../Sources/NUX/WPHelpIndicatorView.swift | 36 + .../Sources/NUX/WPNUXMainButton.h | 8 + .../Sources/NUX/WPNUXMainButton.m | 80 + .../Sources/NUX/WPNUXPrimaryButton.h | 5 + .../Sources/NUX/WPNUXPrimaryButton.m | 58 + .../Sources/NUX/WPNUXSecondaryButton.h | 5 + .../Sources/NUX/WPNUXSecondaryButton.m | 57 + .../Sources/NUX/WPWalkthroughOverlayView.h | 31 + .../Sources/NUX/WPWalkthroughOverlayView.m | 329 + .../Sources/NUX/WPWalkthroughTextField.h | 43 + .../Sources/NUX/WPWalkthroughTextField.m | 296 + .../Sources/Navigation/NavigateBack.swift | 16 + .../Navigation/NavigateToEnterAccount.swift | 30 + .../Navigation/NavigateToEnterSite.swift | 21 + .../NavigateToEnterSiteCredentials.swift | 28 + .../NavigateToEnterWPCOMPassword.swift | 28 + .../Sources/Navigation/NavigateToRoot.swift | 16 + .../Navigation/NavigationCommand.swift | 10 + .../Sources/Private/WPAuthenticator-Swift.h | 8 + .../Sources/Resources/Animations/jetpack.json | 1 + .../Resources/Animations/notifications.json | 1 + .../Sources/Resources/Animations/post.json | 1 + .../Sources/Resources/Animations/reader.json | 1 + .../Sources/Resources/Animations/stats.json | 1 + .../Resources/Assets.xcassets/Contents.json | 6 + .../darkgrey-shadow.imageset/Contents.json | 23 + .../darkgrey-shadow.png | Bin 0 -> 830 bytes .../darkgrey-shadow@2x.png | Bin 0 -> 887 bytes .../darkgrey-shadow@3x.png | Bin 0 -> 986 bytes .../email.imageset/Contents.json | 15 + .../Assets.xcassets/email.imageset/email.pdf | Bin 0 -> 7180 bytes .../google.imageset/Contents.json | 23 + .../google.imageset/google.png | Bin 0 -> 643 bytes .../google.imageset/google@2x.png | Bin 0 -> 1166 bytes .../google.imageset/google@3x.png | Bin 0 -> 1773 bytes .../Contents.json | 12 + .../icon-password-field.pdf | Bin 0 -> 4050 bytes .../Contents.json | 12 + .../icon-post-search-highlight.pdf | Bin 0 -> 4126 bytes .../icon-url-field.imageset/Contents.json | 16 + .../icon-url-field.pdf | Bin 0 -> 4145 bytes .../Contents.json | 12 + .../icon-username-field.pdf | Bin 0 -> 3940 bytes .../key-icon.imageset/Contents.json | 12 + .../Assets.xcassets/key-icon.imageset/key.pdf | Bin 0 -> 3395 bytes .../login-magic-link.imageset/Contents.json | 12 + .../login-magic-link.pdf | Bin 0 -> 436869 bytes .../phone-icon.imageset/Contents.json | 12 + .../phone-icon.imageset/phone.pdf | Bin 0 -> 3127 bytes .../site-address.imageset/Contents.json | 12 + .../site-address.imageset/site-address.pdf | Bin 0 -> 13780 bytes .../Contents.json | 12 + .../social-signup-waiting.pdf | Bin 0 -> 11401 bytes .../SupportedEmailClients/EmailClients.plist | 18 + .../Sources/Services/LoginFacade.h | 174 + .../Sources/Services/LoginFacade.m | 179 + .../Sources/Services/LoginFacade.swift | 110 + .../Services/SafariCredentialsService.swift | 89 + .../Sources/Services/SignupService.swift | 135 + .../Sources/Services/SocialUser.swift | 8 + .../Sources/Services/SocialUserCreating.swift | 23 + .../Services/WordPressComAccountService.swift | 103 + .../Services/WordPressComBlogService.swift | 68 + .../WordPressComOAuthClientFacade.swift | 121 + ...ordPressComOAuthClientFacadeProtocol.swift | 69 + .../Services/WordPressXMLRPCAPIFacade.h | 23 + .../Services/WordPressXMLRPCAPIFacade.m | 99 + .../Sources/Signin/AppleAuthenticator.swift | 292 + .../Sources/Signin/EmailMagicLink.storyboard | 165 + .../Sources/Signin/Login.storyboard | 1481 ++++ .../Signin/Login2FAViewController.swift | 333 + .../Signin/LoginEmailViewController.swift | 634 ++ .../LoginLinkRequestViewController.swift | 172 + .../Signin/LoginNavigationController.swift | 23 + ...ginPrologueLoginMethodViewController.swift | 132 + .../LoginProloguePageViewController.swift | 98 + ...inPrologueSignupMethodViewController.swift | 134 + .../Signin/LoginPrologueViewController.swift | 756 ++ .../LoginSelfHostedViewController.swift | 281 + .../LoginSiteAddressViewController.swift | 351 + .../Sources/Signin/LoginSocialErrorCell.swift | 94 + .../LoginSocialErrorViewController.swift | 218 + .../LoginUsernamePasswordViewController.swift | 245 + .../Sources/Signin/LoginViewController.swift | 541 ++ .../Signin/LoginWPComViewController.swift | 258 + .../Sources/Signin/SigninEditingState.swift | 6 + .../Sources/Signup/Signup.storyboard | 186 + .../Signup/SignupEmailViewController.swift | 241 + .../Signup/SignupGoogleViewController.swift | 80 + .../Signup/SignupNavigationController.swift | 8 + .../Sources/UI/CircularImageView.swift | 22 + .../Sources/UI/LoginTextField.swift | 77 + .../Sources/UI/SearchTableViewCell.swift | 160 + .../Sources/UI/SearchTableViewCell.xib | 55 + .../Sources/UI/SiteInfoHeaderView.swift | 116 + ...WebAuthenticationPresentationContext.swift | 15 + .../Unified Auth/GoogleAuthenticator.swift | 446 ++ .../StoredCredentialsAuthenticator.swift | 245 + .../StoredCredentialsPicker.swift | 55 + .../View Related/2FA/TwoFA.storyboard | 92 + .../2FA/TwoFAViewController.swift | 713 ++ .../Get Started/GetStarted.storyboard | 159 + .../GetStartedViewController.swift | 973 +++ .../View Related/Google/GoogleAuth.storyboard | 58 + .../Google/GoogleAuthViewController.swift | 165 + .../GoogleSignupConfirmation.storyboard | 92 + ...ogleSignupConfirmationViewController.swift | 259 + .../Login/LoginMagicLink.storyboard | 92 + .../Login/LoginMagicLinkViewController.swift | 180 + .../MagicLinkRequestedViewController.swift | 158 + .../MagicLinkRequestedViewController.xib | 153 + .../Login/MagicLinkRequester.swift | 28 + .../View Related/Password/Password.storyboard | 111 + .../Password/PasswordCoordinator.swift | 68 + .../Password/PasswordViewController.swift | 617 ++ .../GravatarEmailTableViewCell.swift | 84 + .../GravatarEmailTableViewCell.xib | 88 + .../TextFieldTableViewCell.swift | 233 + .../Reusable Views/TextFieldTableViewCell.xib | 67 + .../TextLabelTableViewCell.swift | 43 + .../Reusable Views/TextLabelTableViewCell.xib | 44 + .../TextLinkButtonTableViewCell.swift | 76 + .../TextLinkButtonTableViewCell.xib | 83 + .../TextWithLinkTableViewCell.swift | 43 + .../TextWithLinkTableViewCell.xib | 46 + .../SignupMagicLinkViewController.swift | 195 + .../Sign up/UnifiedSignup.storyboard | 172 + .../Sign up/UnifiedSignupViewController.swift | 251 + .../Site Address/SiteAddress.storyboard | 172 + .../SiteAddressViewController.swift | 692 ++ .../Site Address/SiteAddressViewModel.swift | 136 + .../SiteCredentialsViewController.swift | 589 ++ .../VerifyEmail/VerifyEmail.storyboard | 107 + .../VerifyEmailViewController.swift | 259 + .../Sources/WordPressAuthenticator.h | 20 + .../Analytics/AnalyticsTrackerTests.swift | 295 + .../Tests/Authenticator/PasteboardTests.swift | 63 + .../WordPressAuthenticator+TestsUtils.swift | 51 + ...rdPressAuthenticatorDisplayTextTests.swift | 24 + .../WordPressAuthenticatorTests.swift | 195 + .../WordPressSourceTagTests.swift | 131 + .../Tests/Credentials/CredentialsTests.swift | 128 + .../AppSelectorTests.swift | 70 + .../GoogleSignIn/Character+URLSafeTests.swift | 17 + .../GoogleSignIn/CodeVerifier+Fixture.swift | 12 + .../GoogleSignIn/CodeVerifierTests.swift | 62 + .../GoogleSignIn/Data+Base64URLTests.swift | 83 + .../Tests/GoogleSignIn/Data+SHA256Tests.swift | 22 + .../Tests/GoogleSignIn/DataGettingStub.swift | 25 + .../GoogleSignIn/GoogleClientIdTests.swift | 23 + .../GoogleOAuthTokenGetterTests.swift | 50 + .../GoogleOAuthTokenGettingStub.swift | 30 + .../Tests/GoogleSignIn/IDTokenTests.swift | 26 + .../GoogleSignIn/JSONWebToken+Fixtures.swift | 58 + .../Tests/GoogleSignIn/JWTokenTests.swift | 28 + .../NewGoogleAuthenticatorTests.swift | 105 + .../OAuthRequestBody+GoogleSignInTests.swift | 22 + .../OAuthTokenRequestBodyTests.swift | 28 + .../OAuthTokenResponseBody+Fixture.swift | 15 + .../ProofKeyForCodeExchangeTests.swift | 31 + .../Result+ConvenienceInitTests.swift | 40 + .../GoogleSignIn/URL+GoogleSignInTests.swift | 96 + .../URLRequest+GoogleSignInTests.swift | 33 + WordPressAuthenticator/Tests/Info.plist | 22 + .../Tests/Logging/LoggingTests.m | 78 + .../Tests/Logging/LoggingTests.swift | 70 + .../Tests/MemoryManagementTests.swift | 60 + .../Mocks/MockNavigationController.swift | 10 + .../ModalViewControllerPresentingSpy.swift | 8 + .../WordPressAuthenticatorDelegateSpy.swift | 82 + .../WordpressAuthenticatorProvider.swift | 111 + .../Tests/Model/LoginFieldsTests.swift | 29 + .../Model/LoginFieldsValidationTests.swift | 43 + .../Model/WordPressComSiteInfoTests.swift | 50 + .../Tests/Navigation/NavigateBackTests.swift | 18 + .../NavigationToEnterAccountTests.swift | 17 + .../NavigationToEnterSiteTests.swift | 17 + .../Navigation/NavigationToRootTests.swift | 18 + .../Tests/Services/LoginFacadeTests.m | 273 + .../SingIn/AppleAuthenticatorTests.swift | 107 + .../SingIn/LoginViewControllerTests.swift | 33 + .../SingIn/SiteAddressViewModelTests.swift | 125 + ...dPressAuthenticatorTests-Bridging-Header.h | 3 + .../Tests/UnitTests.xctestplan | 63 + .../WordPressAuthenticator.podspec | 45 + WordPressKit/Sources/APIInterface/FilePart.m | 18 + .../WordPressComRESTAPIVersionedPathBuilder.m | 60 + .../Sources/APIInterface/include/FilePart.h | 16 + .../include/WordPressComRESTAPIInterfacing.h | 30 + .../include/WordPressComRESTAPIVersion.h | 9 + .../WordPressComRESTAPIVersionedPathBuilder.h | 14 + .../include/WordPressComRestApiErrorDomain.h | 9 + .../ServiceRemoteWordPressComREST.h | 52 + .../ServiceRemoteWordPressComREST.m | 29 + .../AppTransportSecuritySettings.swift | 75 + .../Sources/CoreAPI/Date+WordPressCom.swift | 21 + .../CoreAPI/DateFormatter+WordPressCom.swift | 15 + WordPressKit/Sources/CoreAPI/Either.swift | 15 + .../HTTPAuthenticationAlertController.swift | 104 + WordPressKit/Sources/CoreAPI/HTTPClient.swift | 336 + .../Sources/CoreAPI/HTTPRequestBuilder.swift | 333 + .../Sources/CoreAPI/MultipartForm.swift | 159 + .../Sources/CoreAPI/NSDate+WordPressCom.swift | 30 + .../Sources/CoreAPI/NonceRetrieval.swift | 96 + .../Sources/CoreAPI/Result+Callback.swift | 19 + .../CoreAPI/SocialLogin2FANonceInfo.swift | 56 + .../Sources/CoreAPI/StringEncoding+IANA.swift | 44 + .../CoreAPI/WebauthChallengeInfo.swift | 28 + .../WordPressAPIError+NSErrorBridge.swift | 116 + .../Sources/CoreAPI/WordPressAPIError.swift | 73 + .../CoreAPI/WordPressComOAuthClient.swift | 812 ++ .../Sources/CoreAPI/WordPressComRestApi.swift | 676 ++ .../Sources/CoreAPI/WordPressOrgRestApi.swift | 269 + .../CoreAPI/WordPressOrgXMLRPCApi.swift | 432 ++ .../CoreAPI/WordPressOrgXMLRPCValidator.swift | 366 + .../Sources/CoreAPI/WordPressRSDParser.swift | 53 + WordPressKit/Sources/WordPressKit/Info.plist | 24 + .../WordPressKit/Logging/WPKitLogging.h | 22 + .../WordPressKit/Logging/WPKitLogging.m | 49 + .../WordPressKit/Logging/WPKitLogging.swift | 19 + .../WordPressKit/Models/AccountSettings.swift | 95 + .../WordPressKit/Models/Activity.swift | 310 + .../JetpackAssistantFeatureDetails.swift | 79 + .../Models/Atomic/AtomicLogs.swift | 47 + .../Models/AutomatedTransferStatus.swift | 40 + .../Models/Blaze/BlazeCampaign.swift | 94 + .../Blaze/BlazeCampaignsSearchResponse.swift | 15 + .../Models/DomainContactInformation.swift | 59 + .../WordPressKit/Models/EditorSettings.swift | 58 + .../Models/Extensions/Date+endOfDay.swift | 9 + .../Extensions/Decodable+Dictionary.swift | 99 + .../Enum+UnknownCaseRepresentable.swift | 12 + .../NSAttributedString+extensions.swift | 32 + .../NSMutableParagraphStyle+extensions.swift | 15 + .../WordPressKit/Models/FeatureFlag.swift | 51 + .../Jetpack Scan/JetpackCredentials.swift | 12 + .../Models/Jetpack Scan/JetpackScan.swift | 75 + .../Jetpack Scan/JetpackScanHistory.swift | 40 + .../Jetpack Scan/JetpackScanThreat.swift | 256 + .../Jetpack Scan/JetpackThreatFixStatus.swift | 79 + .../WordPressKit/Models/JetpackBackup.swift | 29 + .../Models/JetpackRestoreTypes.swift | 35 + .../Models/KeyringConnection.swift | 23 + .../KeyringConnectionExternalUser.swift | 11 + .../Models/Plugins/PluginDirectoryEntry.swift | 294 + .../Plugins/PluginDirectoryFeedPage.swift | 55 + .../Models/Plugins/PluginState.swift | 121 + .../Models/Plugins/SitePlugin.swift | 7 + .../Plugins/SitePluginCapabilities.swift | 11 + .../WordPressKit/Models/ReaderFeed.swift | 75 + .../Models/RemoteBlockEditorSettings.swift | 82 + .../WordPressKit/Models/RemoteBlog.swift | 84 + .../RemoteBlogJetpackModulesSettings.swift | 20 + .../RemoteBlogJetpackMonitorSettings.swift | 21 + .../Models/RemoteBlogJetpackSettings.swift | 44 + .../Models/RemoteBlogOptionsHelper.swift | 79 + .../Models/RemoteBlogSettings.swift | 206 + .../Models/RemoteBloggingPrompt.swift | 58 + .../RemoteBloggingPromptsSettings.swift | 51 + .../WordPressKit/Models/RemoteComment.h | 23 + .../WordPressKit/Models/RemoteComment.m | 5 + .../WordPressKit/Models/RemoteCommentV2.swift | 95 + .../WordPressKit/Models/RemoteDomain.swift | 83 + .../Models/RemoteGravatarProfile.swift | 34 + .../Models/RemoteHomepageType.swift | 12 + .../Models/RemoteInviteLink.swift | 26 + .../Sources/WordPressKit/Models/RemoteMedia.h | 28 + .../Sources/WordPressKit/Models/RemoteMedia.m | 30 + .../WordPressKit/Models/RemoteMenu.swift | 11 + .../WordPressKit/Models/RemoteMenuItem.swift | 19 + .../Models/RemoteMenuLocation.swift | 9 + .../Models/RemoteNotification.swift | 81 + .../Models/RemoteNotificationSettings.swift | 186 + .../Models/RemotePageLayouts.swift | 86 + .../WordPressKit/Models/RemotePerson.swift | 261 + .../Models/RemotePlan_ApiVersion1_3.swift | 44 + .../Sources/WordPressKit/Models/RemotePost.h | 66 + .../Sources/WordPressKit/Models/RemotePost.m | 50 + .../Models/RemotePostAutosave.swift | 15 + .../WordPressKit/Models/RemotePostCategory.h | 7 + .../WordPressKit/Models/RemotePostCategory.m | 18 + .../Models/RemotePostParameters.swift | 448 ++ .../WordPressKit/Models/RemotePostTag.h | 11 + .../WordPressKit/Models/RemotePostTag.m | 19 + .../WordPressKit/Models/RemotePostType.h | 9 + .../WordPressKit/Models/RemotePostType.m | 20 + .../WordPressKit/Models/RemoteProfile.swift | 28 + .../Models/RemotePublicizeConnection.swift | 22 + .../Models/RemotePublicizeInfo.swift | 15 + .../Models/RemotePublicizeService.swift | 16 + .../Models/RemoteReaderCard.swift | 57 + .../Models/RemoteReaderCrossPostMeta.swift | 9 + .../Models/RemoteReaderInterest.swift | 21 + .../WordPressKit/Models/RemoteReaderPost.h | 70 + .../WordPressKit/Models/RemoteReaderPost.m | 718 ++ .../Models/RemoteReaderPost.swift | 17 + .../Models/RemoteReaderSimplePost.swift | 66 + .../Models/RemoteReaderSite.swift | 13 + .../Models/RemoteReaderSiteInfo.swift | 154 + .../RemoteReaderSiteInfoSubscription.swift | 30 + .../Models/RemoteReaderTopic.swift | 43 + .../Models/RemoteShareAppContent.swift | 14 + .../Models/RemoteSharingButton.swift | 11 + .../Models/RemoteSiteDesign.swift | 92 + .../Models/RemoteSourcePostAttribution.h | 17 + .../Models/RemoteSourcePostAttribution.m | 5 + .../Models/RemoteTaxonomyPaging.h | 53 + .../Models/RemoteTaxonomyPaging.m | 5 + .../Sources/WordPressKit/Models/RemoteTheme.h | 26 + .../Sources/WordPressKit/Models/RemoteTheme.m | 5 + .../Models/RemoteUser+Likes.swift | 63 + .../Sources/WordPressKit/Models/RemoteUser.h | 15 + .../Sources/WordPressKit/Models/RemoteUser.m | 5 + .../Models/RemoteVideoPressVideo.swift | 100 + .../WordPressKit/Models/RemoteWpcomPlan.swift | 49 + .../Models/Revisions/RemoteDiff.swift | 110 + .../Models/Revisions/RemoteRevision.swift | 40 + .../WordPressKit/Models/SessionDetails.swift | 35 + .../Stats/Emails/StatsEmailsSummaryData.swift | 94 + .../Insights/StatsAllAnnualInsight.swift | 84 + .../Stats/Insights/StatsAllTimesInsight.swift | 53 + ...StatsAnnualAndMostPopularTimeInsight.swift | 91 + .../Stats/Insights/StatsCommentsInsight.swift | 117 + .../StatsDotComFollowersInsight.swift | 93 + .../Insights/StatsEmailFollowersInsight.swift | 28 + .../Stats/Insights/StatsLastPostInsight.swift | 96 + .../Insights/StatsPostingStreakInsight.swift | 148 + .../Insights/StatsPublicizeInsight.swift | 83 + .../StatsTagsAndCategoriesInsight.swift | 85 + .../Stats/Insights/StatsTodayInsight.swift | 39 + .../Models/Stats/StatsPostDetails.swift | 147 + .../Stats/StatsSubscribersSummaryData.swift | 85 + .../StatsFileDownloadsTimeIntervalData.swift | 65 + .../StatsPublishedPostsTimeIntervalData.swift | 48 + .../StatsSearchTermTimeIntervalData.swift | 68 + .../StatsSummaryTimeIntervalData.swift | 271 + .../StatsTopAuthorsTimeIntervalData.swift | 139 + .../StatsTopClicksTimeIntervalData.swift | 94 + .../StatsTopCountryTimeIntervalData.swift | 87 + .../StatsTopPostsTimeIntervalData.swift | 68 + .../StatsTopReferrersTimeIntervalData.swift | 110 + .../StatsTopVideosTimeIntervalData.swift | 79 + .../StatsTotalsSummaryData.swift | 39 + .../WordPressKit/Models/WPCountry.swift | 6 + .../Sources/WordPressKit/Models/WPState.swift | 6 + .../WordPressKit/Models/WPTimeZone.swift | 98 + .../WordPressKit/Private/WPKit-Swift.h | 8 + .../Services/AccountServiceRemote.h | 113 + ...countServiceRemoteREST+SocialService.swift | 82 + .../Services/AccountServiceRemoteREST.h | 47 + .../Services/AccountServiceRemoteREST.m | 395 + .../Services/AccountSettingsRemote.swift | 228 + .../Services/ActivityServiceRemote.swift | 224 + .../ActivityServiceRemote_ApiVersion1_0.swift | 46 + .../Services/AnnouncementServiceRemote.swift | 103 + .../AtomicAuthenticationServiceRemote.swift | 82 + .../Services/AtomicSiteServiceRemote.swift | 89 + .../Services/AutomatedTransferService.swift | 136 + .../Services/BlazeServiceRemote.swift | 29 + .../BlockEditorSettingsServiceRemote.swift | 45 + .../BlogJetpackSettingsServiceRemote.swift | 263 + .../WordPressKit/Services/BlogServiceRemote.h | 43 + .../Services/BlogServiceRemoteREST.h | 69 + .../Services/BlogServiceRemoteREST.m | 505 ++ .../Services/BlogServiceRemoteXMLRPC.h | 32 + .../Services/BlogServiceRemoteXMLRPC.m | 209 + .../BloggingPromptsServiceRemote.swift | 151 + .../Services/CommentServiceRemote.h | 69 + .../CommentServiceRemoteREST+ApiV2.swift | 65 + .../Services/CommentServiceRemoteREST.h | 100 + .../Services/CommentServiceRemoteREST.m | 538 ++ .../Services/CommentServiceRemoteXMLRPC.h | 7 + .../Services/CommentServiceRemoteXMLRPC.m | 232 + .../Services/DashboardServiceRemote.swift | 48 + .../DomainsServiceRemote+AllDomains.swift | 181 + .../Domains/DomainsServiceRemote.swift | 321 + .../Services/EditorServiceRemote.swift | 65 + .../Services/FeatureFlagRemote.swift | 57 + .../Services/GravatarServiceRemote.swift | 158 + .../HomepageSettingsServiceRemote.swift | 42 + .../Services/IPLocationRemote.swift | 51 + .../Services/JSONDecoderExtension.swift | 52 + .../Services/JetpackAIServiceRemote.swift | 86 + .../Services/JetpackBackupServiceRemote.swift | 136 + .../JetpackCapabilitiesServiceRemote.swift | 41 + .../Services/JetpackProxyServiceRemote.swift | 58 + .../Services/JetpackScanServiceRemote.swift | 161 + .../Services/JetpackServiceRemote.swift | 110 + .../Services/JetpackSocialServiceRemote.swift | 32 + .../Services/MediaServiceRemote.h | 97 + .../Services/MediaServiceRemoteREST.h | 40 + .../Services/MediaServiceRemoteREST.m | 460 ++ .../Services/MediaServiceRemoteXMLRPC.h | 6 + .../Services/MediaServiceRemoteXMLRPC.m | 317 + .../Services/MenusServiceRemote.h | 97 + .../Services/MenusServiceRemote.m | 422 ++ .../NotificationSettingsServiceRemote.swift | 140 + .../NotificationSyncServiceRemote.swift | 184 + .../Services/PageLayoutServiceRemote.swift | 32 + .../Services/PeopleServiceRemote.swift | 638 ++ .../Services/Plans/PlanServiceRemote.swift | 212 + .../PlanServiceRemote_ApiVersion1_3.swift | 63 + .../JetpackPluginManagementClient.swift | 46 + .../PluginManagementClient.swift | 13 + .../SelfHostedPluginManagementClient.swift | 162 + .../PluginDirectoryServiceRemote.swift | 146 + .../Services/PluginServiceRemote.swift | 254 + .../WordPressKit/Services/PostServiceRemote.h | 106 + .../Services/PostServiceRemoteExtended.swift | 31 + .../Services/PostServiceRemoteOptions.h | 86 + .../PostServiceRemoteREST+Extended.swift | 98 + .../PostServiceRemoteREST+Revisions.swift | 91 + .../Services/PostServiceRemoteREST.h | 82 + .../Services/PostServiceRemoteREST.m | 654 ++ .../PostServiceRemoteXMLRPC+Extended.swift | 79 + .../Services/PostServiceRemoteXMLRPC.h | 9 + .../Services/PostServiceRemoteXMLRPC.m | 459 ++ .../Services/ProductServiceRemote.swift | 81 + .../PushAuthenticationServiceRemote.swift | 31 + .../QR Login/QRLoginServiceRemote.swift | 63 + .../QR Login/QRLoginValidationResponse.swift | 12 + .../ReaderPostServiceRemote+Cards.swift | 117 + ...ReaderPostServiceRemote+RelatedPosts.swift | 48 + ...eaderPostServiceRemote+Subscriptions.swift | 145 + .../Services/ReaderPostServiceRemote+V2.swift | 129 + .../Services/ReaderPostServiceRemote.h | 100 + .../Services/ReaderPostServiceRemote.m | 273 + .../ReaderServiceDeliveryFrequency.swift | 12 + .../ReaderSiteSearchServiceRemote.swift | 85 + .../Services/ReaderSiteServiceRemote.h | 126 + .../Services/ReaderSiteServiceRemote.m | 359 + .../Services/ReaderTopicServiceError.swift | 24 + .../ReaderTopicServiceRemote+Interests.swift | 55 + ...eaderTopicServiceRemote+Subscription.swift | 96 + .../Services/ReaderTopicServiceRemote.h | 119 + .../Services/ReaderTopicServiceRemote.m | 330 + .../Services/RemoteConfigRemote.swift | 37 + .../Services/ServiceRemoteWordPressXMLRPC.h | 19 + .../Services/ServiceRemoteWordPressXMLRPC.m | 69 + .../Services/ServiceRequest.swift | 47 + .../ShareAppContentServiceRemote.swift | 42 + .../Services/SharingServiceRemote.swift | 607 ++ .../Services/SiteDesignServiceRemote.swift | 63 + .../SiteManagementServiceRemote.swift | 169 + .../SiteServiceRemoteWordPressComREST.h | 15 + .../SiteServiceRemoteWordPressComREST.m | 17 + .../Services/StatsServiceRemoteV2.swift | 451 ++ .../Services/TaxonomyServiceRemote.h | 86 + .../Services/TaxonomyServiceRemoteREST.h | 7 + .../Services/TaxonomyServiceRemoteREST.m | 358 + .../Services/TaxonomyServiceRemoteXMLRPC.h | 9 + .../Services/TaxonomyServiceRemoteXMLRPC.m | 357 + .../Services/ThemeServiceRemote.h | 156 + .../Services/ThemeServiceRemote.m | 465 ++ .../Services/TimeZoneServiceRemote.swift | 60 + .../Services/TransactionsServiceRemote.swift | 210 + .../Services/UsersServiceRemoteXMLRPC.swift | 30 + ...rdPressComServiceRemote+SiteCreation.swift | 259 + ...rdPressComServiceRemote+SiteSegments.swift | 138 + ...dPressComServiceRemote+SiteVerticals.swift | 150 + ...ComServiceRemote+SiteVerticalsPrompt.swift | 86 + .../Services/WordPressComServiceRemote.h | 105 + .../Services/WordPressComServiceRemote.m | 341 + .../WordPressKit/Utility/ChecksumUtil.swift | 18 + .../Utility/HTTPProtocolHelpers.swift | 61 + .../Utility/NSCharacterSet+URLEncode.swift | 12 + .../Utility/NSMutableDictionary+Helpers.h | 5 + .../Utility/NSMutableDictionary+Helpers.m | 10 + .../WordPressKit/Utility/NSString+MD5.h | 7 + .../WordPressKit/Utility/NSString+MD5.m | 24 + .../Utility/ObjectValidation.swift | 21 + .../Utility/ZendeskMetadata.swift | 29 + .../Sources/WordPressKit/WordPressKit.h | 57 + .../AppTransportSecuritySettingsTests.swift | 103 + .../CoreAPITests/Bundle+SPMSupport.swift | 25 + .../FakeInfoDictionaryObjectProvider.swift | 21 + .../HTTPRequestBuilderTests.swift | 431 ++ .../CoreAPITests/MultipartFormTests.swift | 169 + .../CoreAPITests/NonceRetrievalTests.swift | 77 + .../Tests/CoreAPITests/RSDParserTests.swift | 50 + .../Stubs/HTML/xmlrpc-response-invalid.html | 7 + ...mlrpc-response-mobile-plugin-redirect.html | 8 + ...thenticateWithIDToken2FANeededSuccess.json | 13 + ...enticateWithIDTokenBearerTokenSuccess.json | 13 + ...ithIDTokenExistingUserNeedsConnection.json | 10 + ...ordPressComOAuthAuthenticateSignature.json | 12 + .../JSON/WordPressComOAuthNeeds2FAFail.json | 4 + .../WordPressComOAuthNeedsWebauthnMFA.json | 7 + .../WordPressComOAuthRequestChallenge.json | 33 + .../Stubs/JSON/WordPressComOAuthSuccess.json | 7 + .../WordPressComOAuthWrongPasswordFail.json | 4 + .../WordPressComRestApiFailInvalidInput.json | 4 + .../WordPressComRestApiFailInvalidJSON.json | 4 + ...ressComRestApiFailRequestInvalidToken.json | 4 + .../WordPressComRestApiFailThrottled.json | 3 + .../WordPressComRestApiFailUnauthorized.json | 4 + .../Stubs/JSON/WordPressComRestApiMedia.json | 868 +++ .../WordPressComRestApiMultipleErrors.json | 8 + .../WordPressComSocial2FACodeSuccess.json | 13 + .../Stubs/JSON/me-settings-success.json | 47 + .../CoreAPITests/Stubs/JSON/wp-forbidden.json | 7 + .../CoreAPITests/Stubs/JSON/wp-pages.json | 729 ++ .../Stubs/JSON/wp-reusable-blocks.json | 50 + .../xmlrpc-bad-username-password-error.xml | 17 + .../Stubs/XML/xmlrpc-response-getpost.xml | 39 + .../XML/xmlrpc-response-list-methods.xml | 91 + .../URLRequest+HTTPBodyText.swift | 11 + .../WordPressComOAuthClientTests.swift | 503 ++ .../WordPressComRestApiTests+Error.swift | 25 + .../WordPressComRestApiTests+Locale.swift | 101 + .../WordPressComRestApiTests.swift | 536 ++ .../CoreAPITests/WordPressOrgAPITests.swift | 207 + .../WordPressOrgRestApiTests.swift | 163 + .../WordPressOrgXMLRPCApiTests.swift | 396 + .../WordPressOrgXMLRPCValidatorTests.swift | 392 + .../Tests/WordPressKitTests/Info.plist | 22 + ...p_v2_themes_twentytwentyone-no-colors.json | 197 + .../get_wp_v2_themes_twentytwentyone.json | 248 + ...itor-v1-settings-success-NotThemeJSON.json | 605 ++ ...-editor-v1-settings-success-ThemeJSON.json | 886 +++ .../Domains/get-all-domains-response.json | 180 + .../activity-groups-bad-json-failure.json | 4 + .../Mock Data/activity-groups-success.json | 22 + .../Mock Data/activity-log-auth-failure.json | 4 + .../activity-log-bad-json-failure.json | 28 + .../Mock Data/activity-log-success-1.json | 298 + .../Mock Data/activity-log-success-2.json | 716 ++ .../Mock Data/activity-log-success-3.json | 681 ++ .../Mock Data/activity-restore-success.json | 6 + ...ctivity-rewind-status-restore-failure.json | 11 + ...tivity-rewind-status-restore-finished.json | 10 + ...ity-rewind-status-restore-in-progress.json | 10 + ...activity-rewind-status-restore-queued.json | 11 + .../activity-rewind-status-success.json | 12 + .../atomic-get-auth-cookie-success.json | 12 + ...nd-login-email-invalid-client-failure.json | 4 + ...nd-login-email-invalid-secret-failure.json | 4 + ...auth-send-login-email-no-user-failure.json | 4 + .../auth-send-login-email-success.json | 3 + ...cation-email-already-verified-failure.json | 4 + .../auth-send-verification-email-success.json | 4 + ...-complete-without-download-id-success.json | 9 + .../Mock Data/blaze-campaigns-search.json | 81 + ...ogging-prompts-settings-fetch-success.json | 15 + ...rompts-settings-update-empty-response.json | 3 + ...prompts-settings-update-with-response.json | 17 + .../Mock Data/blogging-prompts-success.json | 30 + .../Mock Data/comment-likes-success.json | 79 + .../comments-v2-edit-context-success.json | 52 + .../comments-v2-view-context-success.json | 44 + ...on-starter-site-designs-empty-designs.json | 54 + ...common-starter-site-designs-malformed.json | 447 ++ .../common-starter-site-designs-success.json | 450 ++ ...d-200-with-drafts-and-scheduled-posts.json | 31 + .../Mock Data/dashboard-400-invalid-card.json | 17 + ...-contact-information-response-success.json | 14 + .../domain-service-all-domain-types.json | 112 + .../Mock Data/domain-service-bad-json.json | 110 + .../Mock Data/domain-service-empty.json | 4 + .../domain-service-invalid-query.json | 4 + .../Mock Data/empty-array.json | 1 + .../WordPressKitTests/Mock Data/empty.json | 1 + .../Mock Data/get-multiple-themes-v1.2.json | 1 + .../Mock Data/get-purchased-themes-v1.1.json | 1 + .../Mock Data/get-single-theme-v1.1.json | 1 + .../Mock Data/is-available-email-failure.json | 5 + .../Mock Data/is-available-email-success.json | 3 + .../is-available-username-failure.json | 5 + .../is-available-username-success.json | 3 + ...passwordless-account-no-account-found.json | 4 + .../is-passwordless-account-success.json | 3 + ...etpack-capabilities-107159616-success.json | 8 + ...jetpack-capabilities-34197361-success.json | 3 + .../jetpack-capabilities-malformed.json | 3 + .../jetpack-scan-enqueue-failure.json | 1 + .../jetpack-scan-enqueue-success.json | 1 + .../jetpack-scan-idle-success-no-threats.json | 21 + .../jetpack-scan-idle-success-threats.json | 96 + .../Mock Data/jetpack-scan-in-progress.json | 19 + .../Mock Data/jetpack-scan-unavailable.json | 14 + ...tpack-service-check-site-failure-data.json | 3 + ...service-check-site-success-no-jetpack.json | 3 + .../jetpack-service-check-site-success.json | 3 + ...pack-service-error-activation-failure.json | 4 + ...pack-service-error-activation-install.json | 4 + ...ack-service-error-activation-response.json | 4 + .../jetpack-service-error-forbidden.json | 4 + ...jetpack-service-error-install-failure.json | 4 + ...etpack-service-error-install-response.json | 4 + ...ack-service-error-invalid-credentials.json | 4 + .../jetpack-service-error-login-failure.json | 4 + ...jetpack-service-error-site-is-jetpack.json | 4 + .../jetpack-service-error-unknown.json | 4 + .../Mock Data/jetpack-service-failure.json | 3 + .../Mock Data/jetpack-service-success.json | 3 + .../Mock Data/jetpack-social-403.json | 7 + .../jetpack-social-no-publicize.json | 5 + .../jetpack-social-with-publicize.json | 10 + .../Mock Data/me-auth-failure.json | 4 + .../Mock Data/me-bad-json-failure.json | 34 + .../me-settings-close-account-failure.json | 4 + .../me-settings-close-account-success.json | 3 + .../Mock Data/me-sites-auth-failure.json | 4 + .../Mock Data/me-sites-bad-json-failure.json | 158 + .../Mock Data/me-sites-empty-success.json | 3 + .../Mock Data/me-sites-success.json | 518 ++ .../me-sites-visibility-bad-json-failure.json | 2 + .../me-sites-visibility-failure.json | 4 + .../me-sites-visibility-success.json | 3 + .../Mock Data/me-success.json | 34 + .../Mock Data/notifications-last-seen.json | 4 + .../Mock Data/notifications-load-all.json | 100 + .../Mock Data/notifications-load-hash.json | 35 + .../Mock Data/notifications-mark-as-read.json | 4 + .../page-layout-blog-layouts-malformed.json | 14 + .../page-layout-blog-layouts-success.json | 1 + .../people-send-invitation-failure.json | 11 + .../people-send-invitation-success.json | 4 + .../people-validate-invitation-failure.json | 11 + .../people-validate-invitation-success.json | 4 + .../Mock Data/plans-me-sites-success.json | 44 + .../Mock Data/plans-mobile-success.json | 390 + .../plugin-directory-jetpack-beta.json | 224 + .../Mock Data/plugin-directory-jetpack.json | 223 + .../Mock Data/plugin-directory-new.json | 2265 ++++++ .../Mock Data/plugin-directory-popular.json | 6546 +++++++++++++++++ .../plugin-directory-rename-xml-rpc.json | 51 + .../plugin-install-already-installed.json | 4 + .../plugin-install-generic-error.json | 4 + .../Mock Data/plugin-install-succeeds.json | 15 + .../plugin-modify-malformed-response.json | 5 + .../plugin-service-remote-auth-failure.json | 4 + ...gin-service-remote-featured-malformed.json | 3 + ...rvice-remote-featured-plugins-invalid.json | 6 + .../plugin-service-remote-featured.json | 62 + .../plugin-state-contact-form-7.json | 14 + .../Mock Data/plugin-state-jetpack.json | 20 + .../plugin-update-gutenberg-needs-update.json | 33 + ...plugin-update-jetpack-already-updated.json | 18 + .../plugin-update-response-malformed.json | 5 + .../post-autosave-mapping-success.json | 38 + .../Mock Data/post-likes-failure.json | 4 + .../Mock Data/post-likes-success.json | 82 + .../Mock Data/post-revisions-failure.json | 4 + .../post-revisions-mapping-success.json | 57 + .../Mock Data/post-revisions-success.json | 128 + .../Mock Data/qrlogin-authenticate-200.json | 4 + .../qrlogin-authenticate-failed-400.json | 7 + .../Mock Data/qrlogin-validate-200.json | 5 + .../Mock Data/qrlogin-validate-400.json | 7 + .../qrlogin-validate-expired-401.json | 7 + .../Mock Data/reader-cards-success.json | 2098 ++++++ .../Mock Data/reader-following-mine.json | 153 + .../Mock Data/reader-interests-success.json | 7 + ...eader-post-comments-subscribe-failure.json | 4 + ...eader-post-comments-subscribe-success.json | 4 + ...-comments-subscription-status-success.json | 4 + ...der-post-comments-unsubscribe-success.json | 4 + ...-comments-update-notification-success.json | 4 + .../reader-post-related-posts-success.json | 1411 ++++ .../Mock Data/reader-posts-success.json | 2945 ++++++++ .../reader-site-search-blog-id-fallback.json | 99 + .../Mock Data/reader-site-search-failure.json | 192 + ...reader-site-search-no-blog-or-feed-id.json | 192 + .../reader-site-search-success-hasmore.json | 194 + .../reader-site-search-success-no-data.json | 29 + .../reader-site-search-success-no-icon.json | 96 + .../Mock Data/reader-site-search-success.json | 194 + .../Mock Data/remote-notification.json | 149 + .../Mock Data/rest-site-settings.json | 80 + .../Mock Data/self-hosted-plugins-get.json | 98 + .../self-hosted-plugins-install.json | 25 + .../Mock Data/share-app-content-success.json | 5 + .../site-active-purchases-auth-failure.json | 4 + ...ite-active-purchases-bad-json-failure.json | 44 + .../site-active-purchases-empty-response.json | 1 + ...-active-purchases-none-active-success.json | 91 + ...e-active-purchases-two-active-success.json | 91 + .../Mock Data/site-comment-success.json | 38 + .../Mock Data/site-comments-success.json | 44 + .../Mock Data/site-creation-success.json | 9 + .../Mock Data/site-delete-auth-failure.json | 4 + .../site-delete-bad-json-failure.json | 121 + .../site-delete-missing-status-failure.json | 121 + .../Mock Data/site-delete-success.json | 122 + .../site-delete-unexpected-json-failure.json | 1 + ...site-email-followers-get-auth-failure.json | 4 + .../site-email-followers-get-failure.json | 4 + ...mail-followers-get-success-more-pages.json | 17 + .../site-email-followers-get-success.json | 17 + .../Mock Data/site-export-auth-failure.json | 4 + .../site-export-bad-json-failure.json | 1 + .../Mock Data/site-export-failure.json | 1 + .../site-export-missing-status-failure.json | 1 + .../Mock Data/site-export-success.json | 1 + .../site-followers-delete-auth-failure.json | 4 + ...ite-followers-delete-bad-json-failure.json | 3 + .../site-followers-delete-failure.json | 4 + .../site-followers-delete-success.json | 4 + .../site-plans-bad-json-failure.json | 311 + .../site-plans-v3-bad-json-failure.json | 93 + .../site-plans-v3-empty-failure.json | 1 + .../Mock Data/site-plans-v3-success.json | 108 + .../Mock Data/site-plugins-error.json | 1 + .../Mock Data/site-plugins-malformed.json | 11 + .../Mock Data/site-plugins-success.json | 159 + .../Mock Data/site-quick-start-failure.json | 4 + .../Mock Data/site-quick-start-success.json | 3 + .../Mock Data/site-roles-auth-failure.json | 4 + .../site-roles-bad-json-failure.json | 127 + .../Mock Data/site-roles-success.json | 128 + .../Mock Data/site-segments-multiple.json | 56 + .../Mock Data/site-segments-single.json | 9 + .../site-users-delete-auth-failure.json | 4 + .../site-users-delete-bad-json-failure.json | 2 + .../site-users-delete-not-member-failure.json | 4 + .../site-users-delete-site-owner-failure.json | 4 + .../Mock Data/site-users-delete-success.json | 3 + ...te-users-update-role-bad-json-failure.json | 13 + .../site-users-update-role-success.json | 14 + ...sers-update-role-unknown-site-failure.json | 4 + ...sers-update-role-unknown-user-failure.json | 4 + .../Mock Data/site-verticals-empty.json | 1 + .../Mock Data/site-verticals-multiple.json | 51 + .../Mock Data/site-verticals-prompt.json | 5 + .../Mock Data/site-verticals-single.json | 12 + .../site-viewers-delete-auth-failure.json | 4 + .../site-viewers-delete-bad-json.json | 3 + .../site-viewers-delete-failure.json | 4 + .../site-viewers-delete-success.json | 4 + .../site-zendesk-metadata-success.json | 5 + .../Mock Data/sites-external-services.json | 37 + .../sites-invites-links-disable-empty.json | 1 + .../sites-invites-links-disable.json | 7 + .../sites-invites-links-generate.json | 32 + .../Mock Data/sites-invites.json | 196 + .../Mock Data/sites-site-active-features.json | 163 + .../sites-site-no-active-features.json | 159 + .../Mock Data/stats-clicks-data.json | 231 + .../Mock Data/stats-countries-data.json | 113 + .../Mock Data/stats-emails-summary.json | 38 + .../Mock Data/stats-file-downloads.json | 20 + .../Mock Data/stats-post-details.json | 5428 ++++++++++++++ .../Mock Data/stats-posts-data.json | 102 + .../Mock Data/stats-published-posts.json | 20 + .../Mock Data/stats-referrer-data.json | 313 + .../stats-referrer-mark-as-spam.json | 3 + .../Mock Data/stats-search-term-result.json | 49 + .../Mock Data/stats-streak-result.json | 49 + .../Mock Data/stats-subscribers.json | 921 +++ .../Mock Data/stats-summary.json | 10 + .../Mock Data/stats-top-authors.json | 455 ++ .../Mock Data/stats-videos-data.json | 72 + .../Mock Data/stats-visits-day.json | 105 + .../stats-visits-month-unit-week.json | 60 + .../Mock Data/stats-visits-month.json | 83 + .../Mock Data/stats-visits-week.json | 105 + .../supported-countries-success.json | 1 + .../Mock Data/supported-states-empty.json | 1 + .../Mock Data/supported-states-success.json | 1 + .../Mock Data/timezones.json | 1 + ...ain-contact-information-response-fail.json | 13 + ...-contact-information-response-success.json | 3 + .../Mock Data/videopress-private-video.json | 19 + .../Mock Data/videopress-public-video.json | 19 + .../videopress-site-default-video.json | 19 + .../Mock Data/videopress-token.json | 3 + .../Mock Data/wp-admin-post-new.html | 18 + .../xmlrpc-malformed-request-xml-error.xml | 21 + ...pc-metaweblog-editpost-bad-xml-failure.xml | 9 + ...aweblog-editpost-change-format-failure.xml | 21 + ...etaweblog-editpost-change-type-failure.xml | 21 + .../xmlrpc-metaweblog-editpost-success.xml | 10 + ...rpc-metaweblog-newpost-bad-xml-failure.xml | 9 + ...eblog-newpost-invalid-posttype-failure.xml | 21 + .../xmlrpc-metaweblog-newpost-success.xml | 10 + .../Mock Data/xmlrpc-response-getprofile.xml | 136 + ...sponse-valid-but-unexpected-dictionary.xml | 20 + .../Mock Data/xmlrpc-site-comment-success.xml | 25 + .../xmlrpc-site-comments-success.xml | 31 + .../xmlrpc-wp-getpost-bad-xml-failure.xml | 50 + .../xmlrpc-wp-getpost-invalid-id-failure.xml | 17 + .../Mock Data/xmlrpc-wp-getpost-success.xml | 51 + .../Tests/AccountServiceRemoteRESTTests.swift | 754 ++ .../Tests/AccountSettingsRemoteTests.swift | 330 + .../Tests/ActivityServiceRemoteTests.swift | 440 ++ .../Tests/ActivityTests.swift | 89 + .../Tests/AllDomainsResultDomainTests.swift | 163 + .../AnnouncementServiceRemoteTests.swift | 26 + ...omicAuthenticationServiceRemoteTests.swift | 38 + .../Tests/BlazeServiceRemoteTests.swift | 96 + ...lockEditorSettingsServiceRemoteTests.swift | 335 + ...logServiceRemote+ActiveFeaturesTests.swift | 49 + .../Tests/BlogServiceRemoteRESTTests.m | 206 + .../BloggingPromptsServiceRemoteTests.swift | 212 + .../CommentServiceRemoteREST+APIv2Tests.swift | 159 + .../CommentServiceRemoteRESTLikesTests.swift | 103 + .../Tests/CommentServiceRemoteRESTTests.swift | 113 + .../CommentServiceRemoteXMLRPCTests.swift | 100 + .../Tests/DashboardServiceRemoteTests.swift | 133 + .../Tests/Date+WordPressComTests.swift | 36 + .../DateFormatter+WordPressComTests.swift | 13 + .../Tests/DomainsServiceRemoteRESTTests.swift | 327 + .../Tests/DynamicMockProvider.swift | 50 + .../Tests/EditorServiceRemoteTests.swift | 247 + .../Decodable+DictionaryTests.swift | 45 + .../Tests/IPLocationRemoteTests.swift | 80 + .../WordPressKitTests/Tests/JSONLoader.swift | 50 + .../JetpackBackupServiceRemoteTests.swift | 171 + ...etpackCapabilitiesServiceRemoteTests.swift | 48 + .../JetpackProxyServiceRemoteTests.swift | 122 + .../Tests/JetpackServiceRemoteTests.swift | 260 + .../Tests/LoadMediaLibraryTests.swift | 110 + .../Tests/MediaLibraryTestSupport.swift | 199 + .../Tests/MediaServiceRemoteRESTTests.swift | 453 ++ .../Tests/MenusServiceRemoteTests.m | 126 + .../MockPluginDirectoryEntryProvider.swift | 77 + .../Tests/MockPluginStateProvider.swift | 129 + .../Tests/MockServiceRequest.swift | 12 + .../Tests/MockWordPressComRestApi.swift | 73 + .../Tests/Models/RemotePersonTests.swift | 65 + .../Models/RemoteVideoPressVideoTests.swift | 82 + .../Emails/StatsEmailsSummaryDataTests.swift | 27 + .../MockData/stats-insight-comments.json | 71 + .../MockData/stats-insight-followers.json | 138 + .../MockData/stats-insight-last-post.json | 203 + .../MockData/stats-insight-publicize.json | 8 + .../MockData/stats-insight-streak.json | 157 + .../MockData/stats-insight-summary.json | 10 + .../stats-insight-tag-and-category.json | 105 + .../V2/Insights/MockData/stats-insight.json | 94 + .../Stats/V2/Insights/MockData/stats.json | 277 + .../StatsDotComFollowersInsightTests.swift | 121 + .../Insights/StatsInsightDecodingTests.swift | 38 + .../V2/StatsSubscribersSummaryDataTests.swift | 24 + .../Tests/NSDate+WordPressComTests.swift | 29 + .../NotificationSyncServiceRemoteTests.swift | 176 + .../Tests/PageLayoutServiceRemoteTests.swift | 75 + .../Tests/PeopleServiceRemoteTests.swift | 798 ++ .../Tests/PlanServiceRemoteTests.swift | 333 + .../Tests/PluginDirectoryTests.swift | 358 + .../Tests/PluginServiceRemoteTests.swift | 529 ++ .../Tests/PluginStateTests.swift | 228 + .../PostServiceRemoteRESTAutosaveTests.swift | 52 + .../PostServiceRemoteRESTLikesTests.swift | 103 + .../PostServiceRemoteRESTRevisionsTest.swift | 122 + .../Tests/PostServiceRemoteRESTTests.m | 401 + .../Tests/PostServiceRemoteXMLRPCTests.swift | 414 ++ ...PushAuthenticationServiceRemoteTests.swift | 57 + .../Tests/QRLoginServiceRemoteTests.swift | 124 + .../Tests/RESTTestable.swift | 13 + .../ReaderPostServiceRemote+CardsTests.swift | 325 + ...PostServiceRemote+FetchEndpointTests.swift | 40 + ...rPostServiceRemote+RelatedPostsTests.swift | 59 + ...rPostServiceRemote+SubscriptionTests.swift | 168 + .../Tests/ReaderPostServiceRemoteTests.m | 101 + .../ReaderSiteSearchServiceRemoteTests.swift | 230 + .../Tests/ReaderSiteServiceRemoteTests.swift | 351 + ...derTopicServiceRemote+InterestsTests.swift | 85 + ...TopicServiceRemoteTest+Subscriptions.swift | 110 + .../Tests/ReaderTopicServiceRemoteTests.swift | 71 + .../Tests/RemoteNotificationTests.swift | 66 + .../Tests/RemoteReaderPostTests+V2.swift | 65 + .../Tests/RemoteReaderPostTests.m | 359 + ...emoteReaderSiteInfoSubscriptionTests.swift | 20 + .../Tests/RemoteTestCase.swift | 235 + .../Scan/JetpackScanServiceRemoteTests.swift | 243 + ...elfHostedPluginManagementClientTests.swift | 114 + .../Tests/ServiceRequestTest.swift | 37 + .../ShareAppContentServiceRemoteTests.swift | 100 + .../Tests/SharingServiceRemoteTests.swift | 130 + .../SiteCreationRequestEncodingTests.swift | 274 + .../SiteCreationResponseDecodingTests.swift | 32 + .../Tests/SiteCreationSegmentsTests.swift | 34 + .../Tests/SiteDesignServiceRemoteTests.swift | 78 + .../SiteManagementServiceRemoteTests.swift | 466 ++ .../Tests/SitePluginTests.swift | 19 + .../SiteSegmentsResponseDecodingTests.swift | 53 + ...VerticalsPromptResponseDecodingTests.swift | 32 + .../SiteVerticalsRequestEncodingTests.swift | 98 + .../SiteVerticalsResponseDecodingTests.swift | 131 + .../JetpackSocialServiceRemoteTests.swift | 79 + ...dMostPopularTimeInsightDecodingTests.swift | 54 + .../Tests/StatsRemoteV2Tests.swift | 717 ++ .../Tests/TaxonomyServiceRemoteRESTTests.m | 318 + .../Tests/TestCollector+Constants.swift | 7 + .../Tests/ThemeServiceRemoteTests.m | 366 + .../Tests/TimeZoneServiceRemoteTests.swift | 55 + .../TransactionsServiceRemoteTests.swift | 34 + .../Tests/UsersServiceRemoteXMLRPCTests.swift | 90 + .../Tests/Utilities/ChecksumUtilTests.swift | 24 + .../Utilities/FeatureFlagRemoteTests.swift | 125 + .../FeatureFlagSerializationTest.swift | 14 + .../Utilities/HTTPBodyEncodingTests.swift | 24 + .../HTTPHeaderValueParserTests.swift | 79 + .../Tests/Utilities/LoggingTests.m | 72 + .../Tests/Utilities/LoggingTests.swift | 70 + .../Utilities/RemoteConfigRemoteTests.swift | 94 + .../Utilities/URLSessionHelperTests.swift | 394 + .../Utilities/WordPressAPIErrorTests.swift | 43 + .../WordPressComServiceRemoteRestTests.swift | 76 + ...ssComServiceRemoteTests+SiteCreation.swift | 55 + ...sComServiceRemoteTests+SiteVerticals.swift | 37 + ...rviceRemoteTests+SiteVerticalsPrompt.swift | 25 + .../Tests/XMLRPCTestable.swift | 27 + .../WordPressKitTests/UnitTests.xctestplan | 66 + .../WordPressKitTests-Bridging-Header.h | 4 + WordPressKit/WordPressKit.podspec | 33 + 979 files changed, 117679 insertions(+) create mode 100644 WordPressAuthenticator/Sources/Analytics/AuthenticatorAnalyticsTracker.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Errors.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Events.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Notifications.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorConfiguration.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDelegateProtocol.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayImages.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayStrings.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorResult.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorStyles.swift create mode 100644 WordPressAuthenticator/Sources/Authenticator/WordPressSupportSourceTag.swift create mode 100644 WordPressAuthenticator/Sources/Credentials/AuthenticatorCredentials.swift create mode 100644 WordPressAuthenticator/Sources/Credentials/WordPressComCredentials.swift create mode 100644 WordPressAuthenticator/Sources/Credentials/WordPressOrgCredentials.swift create mode 100644 WordPressAuthenticator/Sources/Email Client Picker/AppSelector.swift create mode 100644 WordPressAuthenticator/Sources/Email Client Picker/LinkMailPresenter.swift create mode 100644 WordPressAuthenticator/Sources/Email Client Picker/URLHandler.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/FancyAlertViewController+LoginError.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/NSObject+Helpers.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/String+Underline.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIButton+Styles.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIImage+Assets.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIPasteboard+Detect.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIStoryboard+Helpers.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UITableView+Helpers.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIView+AuthHelpers.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIViewController+Dismissal.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/UIViewController+Helpers.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/URL+JetpackConnect.swift create mode 100644 WordPressAuthenticator/Sources/Extensions/WPStyleGuide+Login.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/ASWebAuthenticationSession+Utils.swift .swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/Character+URLSafe.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/Data+Base64URL.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/Data+SHA256.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/DataGetting.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/GoogleClientId.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetter.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetting.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/IDToken.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/JSONWebToken.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/NewGoogleAuthenticator.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/OAuthError.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenRequestBody.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenResponseBody.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/ProofKeyForCodeExchange.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/Result+ConvenienceInit.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/URL+GoogleSignIn.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/URLRequest+GoogleSignIn.swift create mode 100644 WordPressAuthenticator/Sources/GoogleSignIn/URLSesison+DataGetting.swift create mode 100644 WordPressAuthenticator/Sources/Info.plist create mode 100644 WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.h create mode 100644 WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.m create mode 100644 WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.swift create mode 100644 WordPressAuthenticator/Sources/Model/LoginFields+Validation.swift create mode 100644 WordPressAuthenticator/Sources/Model/LoginFields.swift create mode 100644 WordPressAuthenticator/Sources/Model/LoginFieldsMeta.swift create mode 100644 WordPressAuthenticator/Sources/Model/WordPressComSiteInfo.swift create mode 100644 WordPressAuthenticator/Sources/NUX/Button/NUXButton.swift create mode 100644 WordPressAuthenticator/Sources/NUX/Button/NUXButtonView.storyboard create mode 100644 WordPressAuthenticator/Sources/NUX/Button/NUXButtonViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/Button/NUXStackedButtonsViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/ModalViewControllerPresenting.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXKeyboardResponder.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXLinkAuthViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXLinkMailViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXNavigationController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXTableViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXViewController.swift create mode 100644 WordPressAuthenticator/Sources/NUX/NUXViewControllerBase.swift create mode 100644 WordPressAuthenticator/Sources/NUX/WPHelpIndicatorView.swift create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXMainButton.h create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXMainButton.m create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.h create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.m create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.h create mode 100644 WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.m create mode 100644 WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.h create mode 100644 WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.m create mode 100644 WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.h create mode 100644 WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.m create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateBack.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateToEnterAccount.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateToEnterSite.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateToEnterSiteCredentials.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateToEnterWPCOMPassword.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigateToRoot.swift create mode 100644 WordPressAuthenticator/Sources/Navigation/NavigationCommand.swift create mode 100644 WordPressAuthenticator/Sources/Private/WPAuthenticator-Swift.h create mode 100644 WordPressAuthenticator/Sources/Resources/Animations/jetpack.json create mode 100644 WordPressAuthenticator/Sources/Resources/Animations/notifications.json create mode 100644 WordPressAuthenticator/Sources/Resources/Animations/post.json create mode 100644 WordPressAuthenticator/Sources/Resources/Animations/reader.json create mode 100644 WordPressAuthenticator/Sources/Resources/Animations/stats.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow@2x.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow@3x.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/email.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@2x.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@3x.png create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/icon-password-field.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/icon-post-search-highlight.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-url-field.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-url-field.imageset/icon-url-field.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-username-field.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-username-field.imageset/icon-username-field.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/key.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/login-magic-link.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/phone-icon.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/phone-icon.imageset/phone.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/site-address.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/site-address.imageset/site-address.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/social-signup-waiting.imageset/Contents.json create mode 100644 WordPressAuthenticator/Sources/Resources/Assets.xcassets/social-signup-waiting.imageset/social-signup-waiting.pdf create mode 100644 WordPressAuthenticator/Sources/Resources/SupportedEmailClients/EmailClients.plist create mode 100644 WordPressAuthenticator/Sources/Services/LoginFacade.h create mode 100644 WordPressAuthenticator/Sources/Services/LoginFacade.m create mode 100644 WordPressAuthenticator/Sources/Services/LoginFacade.swift create mode 100644 WordPressAuthenticator/Sources/Services/SafariCredentialsService.swift create mode 100644 WordPressAuthenticator/Sources/Services/SignupService.swift create mode 100644 WordPressAuthenticator/Sources/Services/SocialUser.swift create mode 100644 WordPressAuthenticator/Sources/Services/SocialUserCreating.swift create mode 100644 WordPressAuthenticator/Sources/Services/WordPressComAccountService.swift create mode 100644 WordPressAuthenticator/Sources/Services/WordPressComBlogService.swift create mode 100644 WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacade.swift create mode 100644 WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacadeProtocol.swift create mode 100644 WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.h create mode 100644 WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.m create mode 100644 WordPressAuthenticator/Sources/Signin/AppleAuthenticator.swift create mode 100644 WordPressAuthenticator/Sources/Signin/EmailMagicLink.storyboard create mode 100644 WordPressAuthenticator/Sources/Signin/Login.storyboard create mode 100644 WordPressAuthenticator/Sources/Signin/Login2FAViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginEmailViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginLinkRequestViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginNavigationController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginPrologueLoginMethodViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginProloguePageViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginPrologueSignupMethodViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginSelfHostedViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginSiteAddressViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginSocialErrorCell.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginSocialErrorViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginUsernamePasswordViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/LoginWPComViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signin/SigninEditingState.swift create mode 100644 WordPressAuthenticator/Sources/Signup/Signup.storyboard create mode 100644 WordPressAuthenticator/Sources/Signup/SignupEmailViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signup/SignupGoogleViewController.swift create mode 100644 WordPressAuthenticator/Sources/Signup/SignupNavigationController.swift create mode 100644 WordPressAuthenticator/Sources/UI/CircularImageView.swift create mode 100644 WordPressAuthenticator/Sources/UI/LoginTextField.swift create mode 100644 WordPressAuthenticator/Sources/UI/SearchTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/UI/SearchTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/UI/SiteInfoHeaderView.swift create mode 100644 WordPressAuthenticator/Sources/UI/WebAuthenticationPresentationContext.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/GoogleAuthenticator.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsAuthenticator.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsPicker.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFA.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFAViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStarted.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStartedViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuth.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuthViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmation.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmationViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLink.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLinkViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequester.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Password/Password.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordCoordinator.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.xib create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/SignupMagicLinkViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignup.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignupViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddress.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewModel.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteCredentialsViewController.swift create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmail.storyboard create mode 100644 WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmailViewController.swift create mode 100644 WordPressAuthenticator/Sources/WordPressAuthenticator.h create mode 100644 WordPressAuthenticator/Tests/Analytics/AnalyticsTrackerTests.swift create mode 100644 WordPressAuthenticator/Tests/Authenticator/PasteboardTests.swift create mode 100644 WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticator+TestsUtils.swift create mode 100644 WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorDisplayTextTests.swift create mode 100644 WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorTests.swift create mode 100644 WordPressAuthenticator/Tests/Authenticator/WordPressSourceTagTests.swift create mode 100644 WordPressAuthenticator/Tests/Credentials/CredentialsTests.swift create mode 100644 WordPressAuthenticator/Tests/Email Client Picker/AppSelectorTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/Character+URLSafeTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/CodeVerifier+Fixture.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/CodeVerifierTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/Data+Base64URLTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/Data+SHA256Tests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/DataGettingStub.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/GoogleClientIdTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGetterTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGettingStub.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/IDTokenTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/JSONWebToken+Fixtures.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/JWTokenTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/NewGoogleAuthenticatorTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenRequestBodyTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenResponseBody+Fixture.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/ProofKeyForCodeExchangeTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/Result+ConvenienceInitTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/URL+GoogleSignInTests.swift create mode 100644 WordPressAuthenticator/Tests/GoogleSignIn/URLRequest+GoogleSignInTests.swift create mode 100644 WordPressAuthenticator/Tests/Info.plist create mode 100644 WordPressAuthenticator/Tests/Logging/LoggingTests.m create mode 100644 WordPressAuthenticator/Tests/Logging/LoggingTests.swift create mode 100644 WordPressAuthenticator/Tests/MemoryManagementTests.swift create mode 100644 WordPressAuthenticator/Tests/Mocks/MockNavigationController.swift create mode 100644 WordPressAuthenticator/Tests/Mocks/ModalViewControllerPresentingSpy.swift create mode 100644 WordPressAuthenticator/Tests/Mocks/WordPressAuthenticatorDelegateSpy.swift create mode 100644 WordPressAuthenticator/Tests/Mocks/WordpressAuthenticatorProvider.swift create mode 100644 WordPressAuthenticator/Tests/Model/LoginFieldsTests.swift create mode 100644 WordPressAuthenticator/Tests/Model/LoginFieldsValidationTests.swift create mode 100644 WordPressAuthenticator/Tests/Model/WordPressComSiteInfoTests.swift create mode 100644 WordPressAuthenticator/Tests/Navigation/NavigateBackTests.swift create mode 100644 WordPressAuthenticator/Tests/Navigation/NavigationToEnterAccountTests.swift create mode 100644 WordPressAuthenticator/Tests/Navigation/NavigationToEnterSiteTests.swift create mode 100644 WordPressAuthenticator/Tests/Navigation/NavigationToRootTests.swift create mode 100644 WordPressAuthenticator/Tests/Services/LoginFacadeTests.m create mode 100644 WordPressAuthenticator/Tests/SingIn/AppleAuthenticatorTests.swift create mode 100644 WordPressAuthenticator/Tests/SingIn/LoginViewControllerTests.swift create mode 100644 WordPressAuthenticator/Tests/SingIn/SiteAddressViewModelTests.swift create mode 100644 WordPressAuthenticator/Tests/SupportingFiles/WordPressAuthenticatorTests-Bridging-Header.h create mode 100644 WordPressAuthenticator/Tests/UnitTests.xctestplan create mode 100644 WordPressAuthenticator/WordPressAuthenticator.podspec create mode 100644 WordPressKit/Sources/APIInterface/FilePart.m create mode 100644 WordPressKit/Sources/APIInterface/WordPressComRESTAPIVersionedPathBuilder.m create mode 100644 WordPressKit/Sources/APIInterface/include/FilePart.h create mode 100644 WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIInterfacing.h create mode 100644 WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersion.h create mode 100644 WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersionedPathBuilder.h create mode 100644 WordPressKit/Sources/APIInterface/include/WordPressComRestApiErrorDomain.h create mode 100644 WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.h create mode 100644 WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.m create mode 100644 WordPressKit/Sources/CoreAPI/AppTransportSecuritySettings.swift create mode 100644 WordPressKit/Sources/CoreAPI/Date+WordPressCom.swift create mode 100644 WordPressKit/Sources/CoreAPI/DateFormatter+WordPressCom.swift create mode 100644 WordPressKit/Sources/CoreAPI/Either.swift create mode 100644 WordPressKit/Sources/CoreAPI/HTTPAuthenticationAlertController.swift create mode 100644 WordPressKit/Sources/CoreAPI/HTTPClient.swift create mode 100644 WordPressKit/Sources/CoreAPI/HTTPRequestBuilder.swift create mode 100644 WordPressKit/Sources/CoreAPI/MultipartForm.swift create mode 100644 WordPressKit/Sources/CoreAPI/NSDate+WordPressCom.swift create mode 100644 WordPressKit/Sources/CoreAPI/NonceRetrieval.swift create mode 100644 WordPressKit/Sources/CoreAPI/Result+Callback.swift create mode 100644 WordPressKit/Sources/CoreAPI/SocialLogin2FANonceInfo.swift create mode 100644 WordPressKit/Sources/CoreAPI/StringEncoding+IANA.swift create mode 100644 WordPressKit/Sources/CoreAPI/WebauthChallengeInfo.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressAPIError+NSErrorBridge.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressAPIError.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressComOAuthClient.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressComRestApi.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressOrgRestApi.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCApi.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCValidator.swift create mode 100644 WordPressKit/Sources/CoreAPI/WordPressRSDParser.swift create mode 100644 WordPressKit/Sources/WordPressKit/Info.plist create mode 100644 WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.h create mode 100644 WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.m create mode 100644 WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/AccountSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Activity.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Assistant/JetpackAssistantFeatureDetails.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Atomic/AtomicLogs.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/AutomatedTransferStatus.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaign.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaignsSearchResponse.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/DomainContactInformation.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/EditorSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Extensions/Date+endOfDay.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Extensions/Decodable+Dictionary.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Extensions/Enum+UnknownCaseRepresentable.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Extensions/NSAttributedString+extensions.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Extensions/NSMutableParagraphStyle+extensions.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/FeatureFlag.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackCredentials.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScan.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanHistory.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanThreat.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackThreatFixStatus.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/JetpackBackup.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/JetpackRestoreTypes.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/KeyringConnection.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/KeyringConnectionExternalUser.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryEntry.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryFeedPage.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Plugins/PluginState.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Plugins/SitePlugin.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Plugins/SitePluginCapabilities.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/ReaderFeed.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlockEditorSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlog.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackModulesSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackMonitorSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlogOptionsHelper.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBlogSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPrompt.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPromptsSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteComment.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteComment.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteCommentV2.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteDomain.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteGravatarProfile.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteHomepageType.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteInviteLink.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteMedia.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteMedia.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteMenu.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteMenuItem.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteMenuLocation.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteNotification.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteNotificationSettings.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePageLayouts.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePerson.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePlan_ApiVersion1_3.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePost.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePost.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostAutosave.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostParameters.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostTag.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostTag.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostType.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePostType.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteProfile.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePublicizeConnection.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePublicizeInfo.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemotePublicizeService.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderCard.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderCrossPostMeta.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderInterest.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderSimplePost.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderSite.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfo.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfoSubscription.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteReaderTopic.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteShareAppContent.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteSharingButton.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteSiteDesign.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteTheme.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteTheme.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteUser+Likes.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteUser.h create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteUser.m create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteVideoPressVideo.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/RemoteWpcomPlan.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteDiff.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteRevision.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/SessionDetails.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Emails/StatsEmailsSummaryData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllAnnualInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllTimesInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAnnualAndMostPopularTimeInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsCommentsInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsDotComFollowersInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsEmailFollowersInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsLastPostInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPostingStreakInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPublicizeInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTagsAndCategoriesInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTodayInsight.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsPublishedPostsTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSearchTermTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopClicksTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopCountryTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopReferrersTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopVideosTimeIntervalData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTotalsSummaryData.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/WPCountry.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/WPState.swift create mode 100644 WordPressKit/Sources/WordPressKit/Models/WPTimeZone.swift create mode 100644 WordPressKit/Sources/WordPressKit/Private/WPKit-Swift.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/AccountServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST+SocialService.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/AccountSettingsRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote_ApiVersion1_0.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/AnnouncementServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/AtomicAuthenticationServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/AtomicSiteServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/AutomatedTransferService.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlazeServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlockEditorSettingsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogJetpackSettingsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/BloggingPromptsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST+ApiV2.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/DashboardServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote+AllDomains.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/EditorServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/FeatureFlagRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/GravatarServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/HomepageSettingsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/IPLocationRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JSONDecoderExtension.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackAIServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackBackupServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackCapabilitiesServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackProxyServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackScanServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/JetpackSocialServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/MediaServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/NotificationSettingsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/NotificationSyncServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PageLayoutServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PeopleServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote_ApiVersion1_3.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Plugin Management/JetpackPluginManagementClient.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Plugin Management/PluginManagementClient.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/Plugin Management/SelfHostedPluginManagementClient.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PluginDirectoryServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PluginServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteExtended.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteOptions.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Extended.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Revisions.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC+Extended.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/ProductServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/PushAuthenticationServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginValidationResponse.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Cards.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+RelatedPosts.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Subscriptions.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+V2.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderServiceDeliveryFrequency.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderSiteSearchServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceError.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Interests.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Subscription.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/RemoteConfigRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/ServiceRequest.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/ShareAppContentServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/SharingServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/SiteDesignServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/SiteManagementServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Services/TimeZoneServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/TransactionsServiceRemote.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/UsersServiceRemoteXMLRPC.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteCreation.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteSegments.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticals.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticalsPrompt.swift create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.h create mode 100644 WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.m create mode 100644 WordPressKit/Sources/WordPressKit/Utility/ChecksumUtil.swift create mode 100644 WordPressKit/Sources/WordPressKit/Utility/HTTPProtocolHelpers.swift create mode 100644 WordPressKit/Sources/WordPressKit/Utility/NSCharacterSet+URLEncode.swift create mode 100644 WordPressKit/Sources/WordPressKit/Utility/NSMutableDictionary+Helpers.h create mode 100644 WordPressKit/Sources/WordPressKit/Utility/NSMutableDictionary+Helpers.m create mode 100644 WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.h create mode 100644 WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.m create mode 100644 WordPressKit/Sources/WordPressKit/Utility/ObjectValidation.swift create mode 100644 WordPressKit/Sources/WordPressKit/Utility/ZendeskMetadata.swift create mode 100644 WordPressKit/Sources/WordPressKit/WordPressKit.h create mode 100644 WordPressKit/Tests/CoreAPITests/AppTransportSecuritySettingsTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/Bundle+SPMSupport.swift create mode 100644 WordPressKit/Tests/CoreAPITests/FakeInfoDictionaryObjectProvider.swift create mode 100644 WordPressKit/Tests/CoreAPITests/HTTPRequestBuilderTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/MultipartFormTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/NonceRetrievalTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/RSDParserTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-invalid.html create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-mobile-plugin-redirect.html create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDToken2FANeededSuccess.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenBearerTokenSuccess.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenExistingUserNeedsConnection.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthAuthenticateSignature.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeeds2FAFail.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeedsWebauthnMFA.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthRequestChallenge.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthSuccess.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthWrongPasswordFail.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidInput.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidJSON.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailRequestInvalidToken.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailThrottled.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailUnauthorized.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMedia.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMultipleErrors.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComSocial2FACodeSuccess.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/me-settings-success.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-forbidden.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-pages.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-reusable-blocks.json create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/XML/xmlrpc-bad-username-password-error.xml create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/XML/xmlrpc-response-getpost.xml create mode 100644 WordPressKit/Tests/CoreAPITests/Stubs/XML/xmlrpc-response-list-methods.xml create mode 100644 WordPressKit/Tests/CoreAPITests/URLRequest+HTTPBodyText.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressComOAuthClientTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressComRestApiTests+Error.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressComRestApiTests+Locale.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressComRestApiTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressOrgAPITests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressOrgRestApiTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressOrgXMLRPCApiTests.swift create mode 100644 WordPressKit/Tests/CoreAPITests/WordPressOrgXMLRPCValidatorTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Info.plist create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/BlockEditorSettings/get_wp_v2_themes_twentytwentyone-no-colors.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/BlockEditorSettings/get_wp_v2_themes_twentytwentyone.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/BlockEditorSettings/wp-block-editor-v1-settings-success-NotThemeJSON.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/BlockEditorSettings/wp-block-editor-v1-settings-success-ThemeJSON.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/Domains/get-all-domains-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-groups-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-groups-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-log-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-log-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-log-success-1.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-log-success-2.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-log-success-3.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-restore-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-rewind-status-restore-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-rewind-status-restore-finished.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-rewind-status-restore-in-progress.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-rewind-status-restore-queued.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/activity-rewind-status-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/atomic-get-auth-cookie-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-login-email-invalid-client-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-login-email-invalid-secret-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-login-email-no-user-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-login-email-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-verification-email-already-verified-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/auth-send-verification-email-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/backup-get-backup-status-complete-without-download-id-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/blaze-campaigns-search.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/blogging-prompts-settings-fetch-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/blogging-prompts-settings-update-empty-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/blogging-prompts-settings-update-with-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/blogging-prompts-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/comment-likes-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/comments-v2-edit-context-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/comments-v2-view-context-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/common-starter-site-designs-empty-designs.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/common-starter-site-designs-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/common-starter-site-designs-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/dashboard-200-with-drafts-and-scheduled-posts.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/dashboard-400-invalid-card.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/domain-contact-information-response-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/domain-service-all-domain-types.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/domain-service-bad-json.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/domain-service-empty.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/domain-service-invalid-query.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/empty-array.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/empty.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/get-multiple-themes-v1.2.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/get-purchased-themes-v1.1.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/get-single-theme-v1.1.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-available-email-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-available-email-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-available-username-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-available-username-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-passwordless-account-no-account-found.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/is-passwordless-account-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-capabilities-107159616-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-capabilities-34197361-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-capabilities-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-enqueue-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-enqueue-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-idle-success-no-threats.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-idle-success-threats.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-in-progress.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-scan-unavailable.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-check-site-failure-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-check-site-success-no-jetpack.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-check-site-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-activation-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-activation-install.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-activation-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-forbidden.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-install-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-install-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-invalid-credentials.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-login-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-site-is-jetpack.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-error-unknown.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-service-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-social-403.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-social-no-publicize.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/jetpack-social-with-publicize.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-settings-close-account-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-settings-close-account-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-empty-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-visibility-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-visibility-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-sites-visibility-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/me-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/notifications-last-seen.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/notifications-load-all.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/notifications-load-hash.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/notifications-mark-as-read.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/page-layout-blog-layouts-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/page-layout-blog-layouts-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/people-send-invitation-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/people-send-invitation-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/people-validate-invitation-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/people-validate-invitation-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plans-me-sites-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plans-mobile-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-directory-jetpack-beta.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-directory-jetpack.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-directory-new.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-directory-popular.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-directory-rename-xml-rpc.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-install-already-installed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-install-generic-error.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-install-succeeds.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-modify-malformed-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-service-remote-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-service-remote-featured-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-service-remote-featured-plugins-invalid.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-service-remote-featured.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-state-contact-form-7.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-state-jetpack.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-update-gutenberg-needs-update.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-update-jetpack-already-updated.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/plugin-update-response-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-autosave-mapping-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-likes-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-likes-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-revisions-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-revisions-mapping-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/post-revisions-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/qrlogin-authenticate-200.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/qrlogin-authenticate-failed-400.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/qrlogin-validate-200.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/qrlogin-validate-400.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/qrlogin-validate-expired-401.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-cards-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-following-mine.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-interests-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-comments-subscribe-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-comments-subscribe-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-comments-subscription-status-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-comments-unsubscribe-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-comments-update-notification-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-post-related-posts-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-posts-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-blog-id-fallback.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-no-blog-or-feed-id.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-success-hasmore.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-success-no-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-success-no-icon.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/reader-site-search-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/remote-notification.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/rest-site-settings.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/self-hosted-plugins-get.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/self-hosted-plugins-install.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/share-app-content-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-active-purchases-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-active-purchases-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-active-purchases-empty-response.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-active-purchases-none-active-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-active-purchases-two-active-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-comment-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-comments-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-creation-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-delete-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-delete-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-delete-missing-status-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-delete-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-delete-unexpected-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-email-followers-get-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-email-followers-get-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-email-followers-get-success-more-pages.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-email-followers-get-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-export-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-export-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-export-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-export-missing-status-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-export-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-followers-delete-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-followers-delete-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-followers-delete-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-followers-delete-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plans-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plans-v3-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plans-v3-empty-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plans-v3-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plugins-error.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plugins-malformed.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-plugins-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-quick-start-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-quick-start-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-roles-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-roles-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-roles-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-segments-multiple.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-segments-single.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-delete-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-delete-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-delete-not-member-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-delete-site-owner-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-delete-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-update-role-bad-json-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-update-role-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-update-role-unknown-site-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-users-update-role-unknown-user-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-verticals-empty.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-verticals-multiple.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-verticals-prompt.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-verticals-single.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-viewers-delete-auth-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-viewers-delete-bad-json.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-viewers-delete-failure.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-viewers-delete-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/site-zendesk-metadata-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-external-services.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-invites-links-disable-empty.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-invites-links-disable.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-invites-links-generate.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-invites.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-site-active-features.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/sites-site-no-active-features.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-clicks-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-countries-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-emails-summary.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-file-downloads.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-post-details.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-posts-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-published-posts.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-referrer-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-referrer-mark-as-spam.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-search-term-result.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-streak-result.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-subscribers.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-summary.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-top-authors.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-videos-data.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-visits-day.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-visits-month-unit-week.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-visits-month.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/stats-visits-week.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/supported-countries-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/supported-states-empty.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/supported-states-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/timezones.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/validate-domain-contact-information-response-fail.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/validate-domain-contact-information-response-success.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/videopress-private-video.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/videopress-public-video.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/videopress-site-default-video.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/videopress-token.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/wp-admin-post-new.html create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-malformed-request-xml-error.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-editpost-bad-xml-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-editpost-change-format-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-editpost-change-type-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-editpost-success.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-newpost-bad-xml-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-newpost-invalid-posttype-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-metaweblog-newpost-success.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-response-getprofile.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-response-valid-but-unexpected-dictionary.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-site-comment-success.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-site-comments-success.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-wp-getpost-bad-xml-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-wp-getpost-invalid-id-failure.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Mock Data/xmlrpc-wp-getpost-success.xml create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/AccountServiceRemoteRESTTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/AccountSettingsRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ActivityServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ActivityTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/AllDomainsResultDomainTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/AnnouncementServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/AtomicAuthenticationServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/BlazeServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/BlockEditorSettingsServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/BlogServiceRemote+ActiveFeaturesTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/BlogServiceRemoteRESTTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/BloggingPromptsServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/CommentServiceRemoteREST+APIv2Tests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/CommentServiceRemoteRESTLikesTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/CommentServiceRemoteRESTTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/CommentServiceRemoteXMLRPCTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/DashboardServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Date+WordPressComTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/DateFormatter+WordPressComTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/DomainsServiceRemoteRESTTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/DynamicMockProvider.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/EditorServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Extensions/Decodable+DictionaryTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/IPLocationRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/JSONLoader.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/JetpackBackupServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/JetpackCapabilitiesServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/JetpackProxyServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/JetpackServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/LoadMediaLibraryTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MediaLibraryTestSupport.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MediaServiceRemoteRESTTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MenusServiceRemoteTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MockPluginDirectoryEntryProvider.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MockPluginStateProvider.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MockServiceRequest.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/MockWordPressComRestApi.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/RemotePersonTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/RemoteVideoPressVideoTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Emails/StatsEmailsSummaryDataTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-comments.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-followers.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-last-post.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-publicize.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-streak.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-summary.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight-tag-and-category.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats-insight.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/MockData/stats.json create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/StatsDotComFollowersInsightTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/Insights/StatsInsightDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Models/Stats/V2/StatsSubscribersSummaryDataTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/NSDate+WordPressComTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/NotificationSyncServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PageLayoutServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PeopleServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PlanServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PluginDirectoryTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PluginServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PluginStateTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PostServiceRemoteRESTAutosaveTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PostServiceRemoteRESTLikesTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PostServiceRemoteRESTRevisionsTest.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PostServiceRemoteRESTTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PostServiceRemoteXMLRPCTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/PushAuthenticationServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/QRLoginServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RESTTestable.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderPostServiceRemote+CardsTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderPostServiceRemote+FetchEndpointTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderPostServiceRemote+RelatedPostsTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderPostServiceRemote+SubscriptionTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderPostServiceRemoteTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderSiteSearchServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderSiteServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderTopicServiceRemote+InterestsTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderTopicServiceRemoteTest+Subscriptions.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ReaderTopicServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RemoteNotificationTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RemoteReaderPostTests+V2.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RemoteReaderPostTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RemoteReaderSiteInfoSubscriptionTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/RemoteTestCase.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Scan/JetpackScanServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SelfHostedPluginManagementClientTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ServiceRequestTest.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ShareAppContentServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SharingServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteCreationRequestEncodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteCreationResponseDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteCreationSegmentsTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteDesignServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteManagementServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SitePluginTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteSegmentsResponseDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteVerticalsPromptResponseDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteVerticalsRequestEncodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/SiteVerticalsResponseDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Social/JetpackSocialServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/StatsAnnualAndMostPopularTimeInsightDecodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/TaxonomyServiceRemoteRESTTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/TestCollector+Constants.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/ThemeServiceRemoteTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/TimeZoneServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/TransactionsServiceRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/UsersServiceRemoteXMLRPCTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/ChecksumUtilTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/FeatureFlagRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/FeatureFlagSerializationTest.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/HTTPBodyEncodingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/HTTPHeaderValueParserTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/LoggingTests.m create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/LoggingTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/RemoteConfigRemoteTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/URLSessionHelperTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/Utilities/WordPressAPIErrorTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/WordPressComServiceRemoteRestTests.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/WordPressComServiceRemoteTests+SiteCreation.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/WordPressComServiceRemoteTests+SiteVerticals.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/WordPressComServiceRemoteTests+SiteVerticalsPrompt.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/Tests/XMLRPCTestable.swift create mode 100644 WordPressKit/Tests/WordPressKitTests/UnitTests.xctestplan create mode 100644 WordPressKit/Tests/WordPressKitTests/WordPressKitTests-Bridging-Header.h create mode 100644 WordPressKit/WordPressKit.podspec diff --git a/WordPressAuthenticator/Sources/Analytics/AuthenticatorAnalyticsTracker.swift b/WordPressAuthenticator/Sources/Analytics/AuthenticatorAnalyticsTracker.swift new file mode 100644 index 000000000000..9f229931aeae --- /dev/null +++ b/WordPressAuthenticator/Sources/Analytics/AuthenticatorAnalyticsTracker.swift @@ -0,0 +1,512 @@ +import Foundation + +/// Implements the analytics tracking logic for our sign in flow. +/// +public class AuthenticatorAnalyticsTracker { + + private static let defaultSource: Source = .default + private static let defaultFlow: Flow = .prologue + private static let defaultStep: Step = .prologue + + /// The method used for analytics tracking. Useful for overriding in automated tests. + /// + typealias TrackerMethod = (_ event: AnalyticsEvent) -> Void + + public enum EventType: String { + case step = "unified_login_step" + case interaction = "unified_login_interaction" + case failure = "unified_login_failure" + } + + public enum Property: String { + case failure + case flow + case click + case source + case step + } + + public enum Source: String { + /// Starts when the user logs in / sign up from the prologue screen + /// + case `default` + + case jetpack + case share + case deeplink + case reauthentication + + /// Starts when the used adds a site from the site picker + /// + case selfHosted = "self_hosted" + } + + public enum Flow: String { + /// The initial flow before we decide whether the user is logging in or signing up + /// + case wpCom = "wordpress_com" + + /// Flow for Google login + /// + case loginWithGoogle = "google_login" + + /// Flow for Google signup + /// + case signupWithGoogle = "google_signup" + + /// Flow for Apple login + /// + case loginWithApple = "siwa_login" + + /// Flow for Apple signup + /// + case signupWithApple = "siwa_signup" + + /// Flow for iCloud Keychain login + /// + case loginWithiCloudKeychain = "icloud_keychain_login" + + /// The flow that starts when we offer the user the magic link login + /// + case loginWithMagicLink = "login_magic_link" + + /// This flow starts when the user decides to login with a password instead + /// + case loginWithPassword = "login_password" + + /// This flow starts when the user decides to login with a password instead, with magic link logic emphasis + /// where the CTA is a secondary CTA instead of a table view row + /// + case loginWithPasswordWithMagicLinkEmphasis = "login_password_magic_link_emphasis" + + /// This flow starts when the user decides to log in with their site address + /// + case loginWithSiteAddress = "login_site_address" + + /// This flow starts when the user wants to troubleshoot their site by inputting its address + /// + case siteDiscovery = "site_discovery" + + /// This flow represents the signup (when the user inputs an email that’s not registered with a .com account) + /// + case signup + + /// This flow represents the prologue screen. + /// + case prologue + } + + public enum Step: String { + /// Gets shown on the Prologue screen + /// + case prologue + + /// Triggered when a flow is started + /// + case start + + /// Triggered when a user requests a magic link and sees the screen with the “Open mail” button + /// + case magicLinkRequested = "magic_link_requested" + + /// This represents the user opening their mail. It’s not strictly speaking an in-app screen but for the user it is part of the flow. + case emailOpened = "email_opened" + + /// Represents the screen or step in which WPCOM account email is entered by the user + /// + case enterEmailAddress = "enter_email_address" + + /// The screen with a username and password visible + /// + case usernamePassword = "username_password" + + /// The screen that requests the password + /// + case passwordChallenge = "password_challenge" + + /// Triggered on the epilogue screen + /// + case success + + /// Triggered on the help screen + /// + case help + + /// When we ask user to input the code from the 2 factor authentication + case twoFactorAuthentication = "2fa" + + /// Triggered when a user enters site credentials and sees the screen with instructions to verify email. (`VerifyEmailViewController`) + /// + case verifyEmailInstructions = "instructions_to_verify_email" + + /// Triggered when a magic link is automatically requested after filling in email address and the requested screen is shown + /// + case magicLinkAutoRequested = "magic_link_auto_requested" + } + + public enum ClickTarget: String { + /// Tracked when submitting the email form, the email & password form, site address form, + /// username & password form and signup email form + /// + case submit + + /// Tracked when the user clicks on continue in the login/signup epilogue + /// + case `continue` + + /// Tracked when the post signup interstitial screen is dismissed, when the + /// login signup help dialog is dismissed and when the email hint dialog is dismissed + /// + case dismiss + + /// Tracked when the user clicks “Continue with WordPress.com” on the Prologue screen + /// + case continueWithWordPressCom = "continue_with_wordpress_com" + + /// Tracked when the user clicks “What is WordPress.com?" button on the WordPress.com flow screen + /// + case whatIsWPCom = "what_is_wordpress_com" + + /// Tracked when the user clicks “Login with site address” on the Prologue screen + /// + case loginWithSiteAddress = "login_with_site_address" + + /// When the user tries to login with Apple from the confirmation screen + /// + case loginWithApple = "login_with_apple" + + /// Tracked when the user clicks “Login with Google” on the WordPress.com flow screen + /// + case loginWithGoogle = "login_with_google" + + /// When the user clicks on “Forgotten password” on one of the screens that show the password field + /// + case forgottenPassword = "forgotten_password" + + /// When the user clicks on terms of service anywhere + /// + case termsOfService = "terms_of_service_clicked" + + /// When the user tries to sign up with email from the confirmation screen + /// + case signupWithEmail = "signup_with_email" + + /// When the user tries to sign up with Apple from the confirmation screen + /// + case signupWithApple = "signup_with_apple" + + /// When the user tries to sign up with Google from the confirmation screen + /// + case signupWithGoogle = "signup_with_google" + + /// When the user opens the email client from the magic link screen + /// + case openEmailClient = "open_email_client" + + /// Any time the user clicks on the help icon in the login flow + /// + case showHelp = "show_help" + + /// Used on the 2FA screen to send code with a text instead of using the authenticator app + /// + case sendCodeWithText = "send_code_with_text" + + /// Used on the 2FA screen to use a security key instead of using the authenticator app + /// + case enterSecurityKey = "enter_security_key" + + /// Used on the 2FA screen to submit authentication code + /// + case submitTwoFactorCode = "submit_2fa_code" + + /// When the user requests a magic link after filling in email address + /// + case requestMagicLink = "request_magic_link" + + /// Click on “Create new site” button after a successful signup + /// + case createNewSite = "create_new_site" + + /// Adding a self-hosted site from the epilogue + /// + case addSelfHostedSite = "add_self_hosted_site" + + /// Connecting a site from the epilogue + /// + case connectSite = "connect_site" + + /// Picking an avatar from the epilogue after a successful signup + /// + case selectAvatar = "select_avatar" + + /// Editing the username from the epilogue after a successful signup + /// + case editUsername = "edit_username" + + /// Clicking on “Need help finding site address” from a dialog + /// + case helpFindingSiteAddress = "help_finding_site_address" + + /// When the user clicks on the email field to log in, this triggers the hint dialog to show up + /// + case selectEmailField = "select_email_field" + + /// When the user selects an email from the hint dialog + /// + case pickEmailFromHint = "pick_email_from_hint" + + /// When the user clicks on “Create account” on the signup confirmation screen + /// + case createAccount = "create_account" + + /// When the user taps of "Sign in with site credentials" button in `GetStartedViewController` + /// + case signInWithSiteCredentials = "sign_in_with_site_credentials" + + /// When the user clicks on “Login with account password” on `VerifyEmailViewController` + /// + case loginWithAccountPassword = "login_with_password" + } + + public enum Failure: String { + /// Failure to guess XMLRPC URL + /// + case loginFailedToGuessXMLRPC = "login_failed_to_guess_xmlrpc_url" + } + + /// Shared Instance. + /// + public static var shared: AuthenticatorAnalyticsTracker = { + return AuthenticatorAnalyticsTracker() + }() + + /// State for the analytics tracker. + /// + public class State { + internal(set) public var lastFlow: Flow + internal(set) public var lastSource: Source + internal(set) public var lastStep: Step + + init(lastFlow: Flow = AuthenticatorAnalyticsTracker.defaultFlow, lastSource: Source = AuthenticatorAnalyticsTracker.defaultSource, lastStep: Step = AuthenticatorAnalyticsTracker.defaultStep) { + self.lastFlow = lastFlow + self.lastSource = lastSource + self.lastStep = lastStep + } + } + + /// The state of this tracker. + /// + public let state = State() + + /// The backing analytics tracking method. Can be overridden for testing purposes. + /// + let track: TrackerMethod + + /// Whether tracking is enabled or not. This is just a convenience configuration to enable this tracker to be turned on and off + /// using a feature flag. It should go away once we remove the legacy tracking. + /// + let enabled: Bool + + // MARK: - Initializers + + init(enabled: Bool = WordPressAuthenticator.shared.configuration.enableUnifiedAuth, track: @escaping TrackerMethod = WPAnalytics.track) { + self.enabled = enabled + self.track = track + } + + /// Resets the flow and step to the defaults. The source is left untouched, and should only be set explicitely. + /// + func resetState() { + set(flow: Self.defaultFlow) + set(step: Self.defaultStep) + } + + // MARK: - Legacy vs Unified tracking + + /// This method will reply whether, for the current flow in the state, tracking is enabled. + /// + /// It's the responsibility of the class calling the tracking methods to check this before attempting to actually do the tracking. + /// + /// - Returns: `true` if we can track using the state machine. + /// + public func canTrack() -> Bool { + return enabled + } + + /// This is a convenience method, that's useful for cases where we simply want to check if the legacy tracking should be + /// enabled. It can be particularly useful in cases where we don't have a matching tracking call in the new flow. + /// + /// - Returns: `true` if we must use legacy tracking, `false` otherwise. + /// + public func shouldUseLegacyTracker() -> Bool { + return !canTrack() + } + + // MARK: - Tracking + + /// Track a step within a flow. + /// + public func track(step: Step) { + guard canTrack() else { + return + } + + track(event(step: step)) + } + + /// Track a click interaction. + /// + public func track(click: ClickTarget) { + guard canTrack() else { + return + } + + track(event(click: click)) + } + + /// Track a predefined failure enum. + /// + public func track(failure: Failure) { + track(failure: failure.rawValue) + } + + /// Track a failure. + /// + public func track(failure: String) { + guard canTrack() else { + return + } + + track(event(failure: failure)) + } + + // MARK: - Tracking: Legacy Tracking Support + + /// Tracks a step within a flow if tracking is enabled for that flow, or executes the specified block if tracking is not enabled + /// for the flow. + /// + public func track(step: Step, ifTrackingNotEnabled legacyTracking: () -> Void) { + guard canTrack() else { + legacyTracking() + return + } + + track(step: step) + } + + /// Track a click interaction if tracking is enabled for that flow, or executes the specified block if tracking is not enabled + /// for the flow. + /// + public func track(click: ClickTarget, ifTrackingNotEnabled legacyTracking: () -> Void) { + guard canTrack() else { + legacyTracking() + return + } + + track(event(click: click)) + } + + /// Track a failure if tracking is enabled for that flow, or executes the specified block if tracking is not enabled + /// for the flow. + /// + public func track(failure: String, ifTrackingNotEnabled legacyTracking: () -> Void) { + guard canTrack() else { + legacyTracking() + return + } + + track(event(failure: failure)) + } + + // MARK: - Event Construction & Context Updating + + /// Creates an event for a step. Updates the state machine. + /// + /// - Parameters: + /// - step: the step we're tracking. + /// - flow: the flow that the step belongs to. + /// + /// - Returns: an analytics event representing the step. + /// + private func event(step: Step) -> AnalyticsEvent { + let event = AnalyticsEvent( + name: EventType.step.rawValue, + properties: properties(step: step)) + + state.lastStep = step + + return event + } + + /// Creates an event for a failure. Loads the properties from the state machine. + /// + /// - Parameters: + /// - failure: the error message we want to track. + /// + /// - Returns: an analytics event representing the failure. + /// + private func event(failure: String) -> AnalyticsEvent { + var properties = lastProperties() + properties[Property.failure.rawValue] = failure + + return AnalyticsEvent( + name: EventType.failure.rawValue, + properties: properties) + } + + /// Creates an event for a click interaction. Loads the properties from the state machine. + /// + /// - Parameters: + /// - click: the target of the click interaction. + /// + /// - Returns: an analytics event representing the click interaction. + /// + private func event(click: ClickTarget) -> AnalyticsEvent { + var properties = lastProperties() + properties[Property.click.rawValue] = click.rawValue + + return AnalyticsEvent( + name: EventType.interaction.rawValue, + properties: properties) + } + + // MARK: - Source & Flow + + /// Allows the caller to set the flow without tracking. + /// + public func set(flow: Flow) { + state.lastFlow = flow + } + + /// Allows the caller to set the source without tracking. + /// + public func set(source: Source) { + state.lastSource = source + } + + /// Allows the caller to set the step without tracking. + /// + public func set(step: Step) { + state.lastStep = step + } + + // MARK: - Properties + + private func properties(step: Step) -> [String: String] { + return properties(step: step, flow: state.lastFlow, source: state.lastSource) + } + + private func properties(step: Step, flow: Flow, source: Source) -> [String: String] { + return [ + Property.flow.rawValue: flow.rawValue, + Property.source.rawValue: source.rawValue, + Property.step.rawValue: step.rawValue + ] + } + + /// Retrieve the last step, flow and source stored in the state machine. + /// + private func lastProperties() -> [String: String] { + return properties(step: state.lastStep, flow: state.lastFlow, source: state.lastSource) + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Errors.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Errors.swift new file mode 100644 index 000000000000..7d86cb7b6b28 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Errors.swift @@ -0,0 +1,15 @@ +import Foundation + +// MARK: - WordPressAuthenticator Error Constants. Once the entire code is Swifted, let's *PLEASE* have a +// beautiful Error `Swift Enum`. +// +extension WordPressAuthenticator { + + /// Error Domain for Authentication issues. + /// + @objc public static let errorDomain = "org.wordpress.ios.authenticator" + + /// "Invalid Version" Error Code. Used whenever the remote WordPress.org endpoint is below the supported version. + /// + @objc public static let invalidVersionErrorCode = 5000 +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Events.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Events.swift new file mode 100644 index 000000000000..ed97b8e0e0c5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Events.swift @@ -0,0 +1,36 @@ +import Foundation +import WordPressShared + +// MARK: - Authentication Flow Event. Useful to relay internal Auth events over to activity trackers. +// +extension WordPressAuthenticator { + + /// Tracks the specified event. + /// + @objc + public static func track(_ event: WPAnalyticsStat) { + WordPressAuthenticator.shared.delegate?.track(event: event) + } + + /// Tracks the specified event, with the specified properties. + /// + @objc + public static func track(_ event: WPAnalyticsStat, properties: [AnyHashable: Any]) { + WordPressAuthenticator.shared.delegate?.track(event: event, properties: properties) + } + + /// Tracks the specified event, with the associated Error. + /// + /// Note: Ideally speaking... `Error` is not optional. *However* this method is to be used in the ObjC realm, where not everything + /// has it's nullability specifier set. We're just covering unexpected scenarios. + /// + @objc + public static func track(_ event: WPAnalyticsStat, error: Error?) { + guard let error = error else { + track(event) + return + } + + WordPressAuthenticator.shared.delegate?.track(event: event, error: error) + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Notifications.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Notifications.swift new file mode 100644 index 000000000000..718de521422d --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator+Notifications.swift @@ -0,0 +1,19 @@ +// MARK: - WordPressAuthenticator-Y Notifications +// +extension NSNotification.Name { + /// Posted whenever the Login Flow has been cancelled. + /// + public static let wordpressLoginCancelled = Foundation.Notification.Name(rawValue: "WordPressLoginCancelled") + + /// Posted whenever a Jetpack Login was successfully performed. + /// + public static let wordpressLoginFinishedJetpackLogin = Foundation.Notification.Name(rawValue: "WordPressLoginFinishedJetpackLogin") + + /// Posted whenever a Support notification is received. + /// + public static let wordpressSupportNotificationReceived = NSNotification.Name(rawValue: "WordPressSupportNotificationReceived") + + /// Posted whenever a Support notification has been viewed. + /// + public static let wordpressSupportNotificationCleared = NSNotification.Name(rawValue: "WordPressSupportNotificationCleared") +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift new file mode 100644 index 000000000000..f3b17d6df9eb --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticator.swift @@ -0,0 +1,567 @@ +import AuthenticationServices +import NSURL_IDN +import UIKit +import WordPressShared +import WordPressUI +import WordPressKit + +// MARK: - WordPressAuthenticator: Public API to deal with WordPress.com and WordPress.org authentication. +// +@objc public class WordPressAuthenticator: NSObject { + + /// (Private) Shared Instance. + /// + private static var privateInstance: WordPressAuthenticator? + + /// Observer for AppleID Credential State + /// + private var appleIDCredentialObserver: NSObjectProtocol? + + /// Optional sign in source that could be from the login prologue or the host app to track the entry point + /// for customizations in the epilogue handling. + var signInSource: SignInSource? + + /// Shared Instance. + /// + @objc public static var shared: WordPressAuthenticator { + guard let privateInstance = privateInstance else { + fatalError("WordPressAuthenticator wasn't initialized") + } + + return privateInstance + } + + /// Authenticator's Delegate. + /// + public weak var delegate: WordPressAuthenticatorDelegate? + + /// Authenticator's Configuration. + /// + public let configuration: WordPressAuthenticatorConfiguration + + /// Authenticator's Styles. + /// + public let style: WordPressAuthenticatorStyle + + /// Authenticator's Styles for unified flows. + /// + public let unifiedStyle: WordPressAuthenticatorUnifiedStyle? + + /// Authenticator's Display Images. + /// + public let displayImages: WordPressAuthenticatorDisplayImages + + /// Authenticator's Display Texts. + /// + public let displayStrings: WordPressAuthenticatorDisplayStrings + + /// Notification to be posted whenever the signing flow completes. + /// + @objc public static let WPSigninDidFinishNotification = "WPSigninDidFinishNotification" + + /// The host name that identifies magic link URLs + /// + private static let magicLinkUrlHostname = "magic-login" + + // MARK: - Initialization + + /// Designated Initializer + /// + init(configuration: WordPressAuthenticatorConfiguration, + style: WordPressAuthenticatorStyle, + unifiedStyle: WordPressAuthenticatorUnifiedStyle?, + displayImages: WordPressAuthenticatorDisplayImages, + displayStrings: WordPressAuthenticatorDisplayStrings) { + self.configuration = configuration + self.style = style + self.unifiedStyle = unifiedStyle + self.displayImages = displayImages + self.displayStrings = displayStrings + } + + /// Initializes the WordPressAuthenticator with the specified Configuration. + /// + public static func initialize(configuration: WordPressAuthenticatorConfiguration, + style: WordPressAuthenticatorStyle, + unifiedStyle: WordPressAuthenticatorUnifiedStyle?, + displayImages: WordPressAuthenticatorDisplayImages = .defaultImages, + displayStrings: WordPressAuthenticatorDisplayStrings = .defaultStrings) { + privateInstance = WordPressAuthenticator(configuration: configuration, + style: style, + unifiedStyle: unifiedStyle, + displayImages: displayImages, + displayStrings: displayStrings) + } + + // MARK: - Testing Support + + class func isInitialized() -> Bool { + return privateInstance != nil + } + + // MARK: - Public Methods + + public func supportPushNotificationReceived() { + NotificationCenter.default.post(name: .wordpressSupportNotificationReceived, object: nil) + } + + public func supportPushNotificationCleared() { + NotificationCenter.default.post(name: .wordpressSupportNotificationCleared, object: nil) + } + + /// Indicates if the specified ViewController belongs to the Authentication Flow, or not. + /// + public class func isAuthenticationViewController(_ viewController: UIViewController) -> Bool { + return viewController is NUXViewControllerBase + } + + /// Indicates if the received URL is a Google Authentication Callback. + /// + @objc public func isGoogleAuthUrl(_ url: URL) -> Bool { + return url.absoluteString.hasPrefix(configuration.googleLoginScheme) + } + + /// Indicates if the received URL is a WordPress.com Authentication Callback. + /// + @objc public func isWordPressAuthUrl(_ url: URL) -> Bool { + let expectedPrefix = configuration.wpcomScheme + "://" + Self.magicLinkUrlHostname + return url.absoluteString.hasPrefix(expectedPrefix) + } + + /// Attempts to process the specified URL as a WordPress Authentication Link. Returns *true* on success. + /// + @objc public func handleWordPressAuthUrl(_ url: URL, rootViewController: UIViewController, automatedTesting: Bool = false) -> Bool { + return WordPressAuthenticator.openAuthenticationURL(url, fromRootViewController: rootViewController, automatedTesting: automatedTesting) + } + + // MARK: - Helpers for presenting the login flow + + /// Used to present the new login flow from the app delegate + @objc public class func showLoginFromPresenter(_ presenter: UIViewController, animated: Bool) { + showLogin(from: presenter, animated: animated) + } + + /// Shows login UI from the given presenter view controller. + /// + /// - Parameters: + /// - presenter: The view controller that presents the login UI. + /// - animated: Whether the login UI is presented with animation. + /// - showCancel: Whether a cancel CTA is shown on the login prologue screen. + /// - restrictToWPCom: Whether only WordPress.com login is enabled. + /// - onLoginButtonTapped: Called when the login button on the prologue screen is tapped. + /// - onCompletion: Called when the login UI presentation completes. + public class func showLogin(from presenter: UIViewController, animated: Bool, showCancel: Bool = false, restrictToWPCom: Bool = false, onLoginButtonTapped: (() -> Void)? = nil, onCompletion: (() -> Void)? = nil) { + guard let loginViewController = loginUI(showCancel: showCancel, restrictToWPCom: restrictToWPCom, onLoginButtonTapped: onLoginButtonTapped) else { + return + } + presenter.present(loginViewController, animated: animated, completion: onCompletion) + trackOpenedLogin() + } + + /// Returns the view controller for the login flow. + /// The caller is responsible for tracking `.openedLogin` event when displaying the view controller as in `showLogin`. + /// + /// - Parameters: + /// - showCancel: Whether a cancel CTA is shown on the login prologue screen. + /// - restrictToWPCom: Whether only WordPress.com login is enabled. + /// - onLoginButtonTapped: Called when the login button on the prologue screen is tapped. + /// - Returns: The root view controller for the login flow. + public class func loginUI(showCancel: Bool = false, restrictToWPCom: Bool = false, onLoginButtonTapped: (() -> Void)? = nil) -> UIViewController? { + let storyboard = Storyboard.login.instance + guard let controller = storyboard.instantiateInitialViewController() else { + assertionFailure("Cannot instantiate initial login controller from Login.storyboard") + return nil + } + + if let loginNavController = controller as? LoginNavigationController, let loginPrologueViewController = loginNavController.viewControllers.first as? LoginPrologueViewController { + loginPrologueViewController.showCancel = showCancel + } + + controller.modalPresentationStyle = .fullScreen + return controller + } + + /// Used to present the new wpcom-only login flow from the app delegate + @objc public class func showLoginForJustWPCom(from presenter: UIViewController, jetpackLogin: Bool = false, connectedEmail: String? = nil, siteURL: String? = nil) { + defer { + trackOpenedLogin() + } + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + showEmailLogin(from: presenter, jetpackLogin: jetpackLogin, connectedEmail: connectedEmail, siteURL: siteURL) + return + } + + showGetStarted(from: presenter, jetpackLogin: jetpackLogin, connectedEmail: connectedEmail, siteURL: siteURL) + } + + /// Used to present the Verify Email flow from the app delegate. + /// + /// - Parameters: + /// - presenter: The view controller that presents the Verify Email view. + /// - xmlrpc: The URL to reach the XMLRPC file of the site to log in to. + /// - connectedEmail: The email address used to authorized Jetpack connection with the site. + /// - siteURL: The URL of the site to log in to. + /// + @objc public class func showVerifyEmailForWPCom(from presenter: UIViewController, xmlrpc: String, connectedEmail: String, siteURL: String) { + let loginFields = LoginFields() + loginFields.meta.xmlrpcURL = NSURL(string: xmlrpc) + loginFields.username = connectedEmail + loginFields.siteAddress = siteURL + + guard let vc = VerifyEmailViewController.instantiate(from: .verifyEmail) else { + WPAuthenticatorLogError("Failed to navigate to VerifyEmailViewController") + return + } + + vc.loginFields = loginFields + let navController = LoginNavigationController(rootViewController: vc) + navController.modalPresentationStyle = .fullScreen + presenter.present(navController, animated: true, completion: nil) + } + + /// Used to present the site credential login flow directly from the delegate. + /// + /// - Parameters: + /// - presenter: The view controller that presents the site credential login flow. + /// - siteURL: The URL of the site to log in to. + /// - onCompletion: The closure to be trigged when the login succeeds with the input credentials. + /// + public class func showSiteCredentialLogin(from presenter: UIViewController, siteURL: String, onCompletion: @escaping (WordPressOrgCredentials) -> Void) { + let controller = SiteCredentialsViewController.instantiate(from: .siteAddress) { coder in + SiteCredentialsViewController(coder: coder, isDismissible: true, onCompletion: onCompletion) + } + guard let controller = controller else { + WPAuthenticatorLogError("Failed to navigate from GetStartedViewController to SiteCredentialsViewController") + return + } + + let loginFields = LoginFields() + loginFields.siteAddress = siteURL + controller.loginFields = loginFields + controller.dismissBlock = { _ in + controller.navigationController?.dismiss(animated: true) + } + + let navController = LoginNavigationController(rootViewController: controller) + navController.modalPresentationStyle = .fullScreen + presenter.present(navController, animated: true, completion: nil) + } + + /// A helper method to fetch site info for a given URL. + /// - Parameters: + /// - siteURL: The URL of the site to fetch information for. + /// - onCompletion: The closure to be triggered when fetching site info is done. + /// + public class func fetchSiteInfo(for siteURL: String, onCompletion: @escaping (Result) -> Void) { + let service = WordPressComBlogService() + service.fetchUnauthenticatedSiteInfoForAddress(for: siteURL, success: { siteInfo in + onCompletion(.success(siteInfo)) + }, failure: { error in + onCompletion(.failure(error)) + }) + } + + /// Shows the unified Login/Signup flow. + /// + private class func showGetStarted(from presenter: UIViewController, jetpackLogin: Bool, connectedEmail: String? = nil, siteURL: String? = nil) { + guard let controller = GetStartedViewController.instantiate(from: .getStarted) else { + WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to GetStartedViewController") + return + } + + controller.loginFields.restrictToWPCom = true + controller.loginFields.username = connectedEmail ?? String() + controller.loginFields.meta.jetpackLogin = jetpackLogin + if let siteURL = siteURL { + controller.loginFields.siteAddress = siteURL + } + + let navController = LoginNavigationController(rootViewController: controller) + navController.modalPresentationStyle = .fullScreen + presenter.present(navController, animated: true, completion: nil) + } + + /// Shows the Email Login view with Signup option. + /// + private class func showEmailLogin(from presenter: UIViewController, jetpackLogin: Bool, connectedEmail: String? = nil, siteURL: String? = nil) { + guard let controller = LoginEmailViewController.instantiate(from: .login) else { + return + } + + controller.loginFields.restrictToWPCom = true + controller.loginFields.meta.jetpackLogin = jetpackLogin + if let siteURL = siteURL { + controller.loginFields.siteAddress = siteURL + } + + if let email = connectedEmail { + controller.loginFields.username = email + } else { + controller.offerSignupOption = true + } + + let navController = LoginNavigationController(rootViewController: controller) + navController.modalPresentationStyle = .fullScreen + presenter.present(navController, animated: true, completion: nil) + } + + /// Used to present the new self-hosted login flow from BlogListViewController + @objc public class func showLoginForSelfHostedSite(_ presenter: UIViewController) { + defer { + trackOpenedLogin() + } + + AuthenticatorAnalyticsTracker.shared.set(source: .selfHosted) + + guard let controller = signinForWPOrg() else { + WPAuthenticatorLogError("WordPressAuthenticator: Failed to instantiate Site Address view controller.") + return + } + + let navController = LoginNavigationController(rootViewController: controller) + navController.modalPresentationStyle = .fullScreen + presenter.present(navController, animated: true, completion: nil) + } + + /// Returns a Site Address view controller: allows the user to log into a WordPress.org website. + /// + @objc public class func signinForWPOrg() -> UIViewController? { + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + return LoginSiteAddressViewController.instantiate(from: .login) + } + + return SiteAddressViewController.instantiate(from: .siteAddress) + } + + /// Returns a Site Address view controller and triggers the protocol method `troubleshootSite` after fetching the site info. + /// + @objc public class func siteDiscoveryUI() -> UIViewController? { + return SiteAddressViewController.instantiate(from: .siteAddress) { coder in + SiteAddressViewController(isSiteDiscovery: true, coder: coder) + } + } + + // Helper used by WPAuthTokenIssueSolver + @objc + public class func signinForWPCom(dotcomEmailAddress: String?, dotcomUsername: String?, onDismissed: ((_ cancelled: Bool) -> Void)? = nil) -> UIViewController { + let loginFields = LoginFields() + loginFields.emailAddress = dotcomEmailAddress ?? String() + loginFields.username = dotcomUsername ?? String() + + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + guard let controller = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("WordPressAuthenticator: Failed to instantiate LoginWPComViewController") + return UIViewController() + } + + controller.loginFields = loginFields + controller.dismissBlock = onDismissed + + return NUXNavigationController(rootViewController: controller) + } + + AuthenticatorAnalyticsTracker.shared.set(source: .reauthentication) + AuthenticatorAnalyticsTracker.shared.set(flow: .loginWithPassword) + + guard let controller = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("WordPressAuthenticator: Failed to instantiate PasswordViewController") + return UIViewController() + } + + controller.loginFields = loginFields + controller.dismissBlock = onDismissed + controller.trackAsPasswordChallenge = false + + return NUXNavigationController(rootViewController: controller) + } + + /// Returns an instance of LoginEmailViewController. + /// This allows the host app to configure the controller's features. + /// + public class func signinForWPCom() -> LoginEmailViewController { + guard let controller = LoginEmailViewController.instantiate(from: .login) else { + fatalError() + } + + return controller + } + + private class func trackOpenedLogin() { + WordPressAuthenticator.track(.openedLogin) + } + + // MARK: - Authentication Link Helpers + + /// Present a signin view controller to handle an authentication link. + /// + /// - Parameters: + /// - url: The authentication URL + /// - rootViewController: The view controller to act as the presenter for the signin view controller. By convention this is the app's root vc. + /// - automatedTesting: for calling this method for automated testing. It won't sync the account or load any other VCs. + /// + @objc public class func openAuthenticationURL( + _ url: URL, + fromRootViewController rootViewController: UIViewController, + automatedTesting: Bool = false) -> Bool { + + guard let queryDictionary = url.query?.dictionaryFromQueryString() else { + WPAuthenticatorLogError("Magic link error: we couldn't retrieve the query dictionary from the sign-in URL.") + return false + } + + guard let authToken = queryDictionary.string(forKey: "token") else { + WPAuthenticatorLogError("Magic link error: we couldn't retrieve the authentication token from the sign-in URL.") + return false + } + + guard let flowRawValue = queryDictionary.string(forKey: "flow") else { + WPAuthenticatorLogError("Magic link error: we couldn't retrieve the flow from the sign-in URL.") + return false + } + + let loginFields = LoginFields() + + if url.isJetpackConnect { + loginFields.meta.jetpackLogin = true + } + + // We could just use the flow, but since `MagicLinkFlow` is an ObjC enum, it always + // allows a `default` value. By mapping the ObjC enum to a Swift enum we can avoid that afterwards. + let flow: NUXLinkAuthViewController.Flow + + switch MagicLinkFlow(rawValue: flowRawValue) { + case .signup: + flow = .signup + loginFields.meta.emailMagicLinkSource = .signup + Self.track(.signupMagicLinkOpened) + case .login: + flow = .login + loginFields.meta.emailMagicLinkSource = .login + Self.track(.loginMagicLinkOpened) + default: + WPAuthenticatorLogError("Magic link error: the flow should be either `signup` or `login`. We can't handle an unsupported flow.") + return false + } + + if !automatedTesting { + let storyboard = Storyboard.emailMagicLink.instance + guard let loginVC = storyboard.instantiateViewController(withIdentifier: "LinkAuthView") as? NUXLinkAuthViewController else { + WPAuthenticatorLogInfo("App opened with authentication link but couldn't create login screen.") + return false + } + loginVC.loginFields = loginFields + + let navController = LoginNavigationController(rootViewController: loginVC) + navController.modalPresentationStyle = .fullScreen + + // The way the magic link flow works some view controller might + // still be presented when the app is resumed by tapping on the auth link. + // We need to do a little work to present the SigninLinkAuth controller + // from the right place. + // - If the rootViewController is not presenting another vc then just + // present the auth controller. + // - If the rootViewController is presenting another NUX vc, dismiss the + // NUX vc then present the auth controller. + // - If the rootViewController is presenting *any* other vc, present the + // auth controller from the presented vc. + let presenter = rootViewController.topmostPresentedViewController + if presenter.isKind(of: NUXNavigationController.self) || presenter.isKind(of: LoginNavigationController.self), + let parent = presenter.presentingViewController { + parent.dismiss(animated: false, completion: { + parent.present(navController, animated: false, completion: nil) + }) + } else { + presenter.present(navController, animated: false, completion: nil) + } + + loginVC.syncAndContinue(authToken: authToken, flow: flow, isJetpackConnect: url.isJetpackConnect) + } + + return true + } + + // MARK: - Site URL helper + + /// The base site URL path derived from `loginFields.siteUrl` + /// + /// - Parameter string: The source URL as a string. + /// + /// - Returns: The base URL or an empty string. + /// + class func baseSiteURL(string: String) -> String { + + guard !string.isEmpty, + let siteURL = NSURL(string: NSURL.idnEncodedURL(string)), + var path = siteURL.absoluteString else { + return "" + } + + let isSiteURLSchemeEmpty = siteURL.scheme == nil || siteURL.scheme!.isEmpty + + if isSiteURLSchemeEmpty { + path = "https://\(path)" + } else if path.isWordPressComPath() && path.range(of: "http://") != nil { + path = path.replacingOccurrences(of: "http://", with: "https://") + } + + path.removeSuffix("/wp-login.php") + + // Remove wp-admin and everything after it. + try? path.removeSuffix(pattern: "/wp-admin(.*)") + + path.removeSuffix("/") + + return path + } + + // MARK: - Other Helpers + + /// Opens Safari to display the forgot password page for a wpcom or self-hosted + /// based on the passed LoginFields instance. + /// + /// - Parameter loginFields: A LoginFields instance. + /// + public class func openForgotPasswordURL(_ loginFields: LoginFields) { + let baseURL = loginFields.meta.userIsDotCom ? "https://wordpress.com" : WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + let forgotPasswordURL = URL(string: baseURL + "/wp-login.php?action=lostpassword&redirect_to=wordpress%3A%2F%2F")! + UIApplication.shared.open(forgotPasswordURL) + } + + /// Returns the WordPressAuthenticator Bundle + /// If installed via CocoaPods, this will be WordPressAuthenticator.bundle, + /// otherwise it will be the framework bundle. + /// + public class var bundle: Bundle { + let defaultBundle = Bundle(for: WordPressAuthenticator.self) + // If installed with CocoaPods, resources will be in WordPressAuthenticator.bundle + if let bundleURL = defaultBundle.resourceURL, + let resourceBundle = Bundle(url: bundleURL.appendingPathComponent("WordPressAuthenticatorResources.bundle")) { + return resourceBundle + } + // Otherwise, the default bundle is used for resources + return defaultBundle + } +} + +public extension WordPressAuthenticator { + + func getAppleIDCredentialState(for userID: String, completion: @escaping (ASAuthorizationAppleIDProvider.CredentialState, Error?) -> Void) { + AppleAuthenticator.sharedInstance.getAppleIDCredentialState(for: userID) { (state, error) in + // If credentialState == .notFound, error will have a value. + completion(state, error) + } + } + + func startObservingAppleIDCredentialRevoked(completion: @escaping () -> Void) { + appleIDCredentialObserver = NotificationCenter.default.addObserver(forName: AppleAuthenticator.credentialRevokedNotification, object: nil, queue: nil) { (_) in + completion() + } + } + + func stopObservingAppleIDCredentialRevoked() { + if let observer = appleIDCredentialObserver { + NotificationCenter.default.removeObserver(observer) + } + appleIDCredentialObserver = nil + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorConfiguration.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorConfiguration.swift new file mode 100644 index 000000000000..c7cfc9db7bd5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorConfiguration.swift @@ -0,0 +1,258 @@ +import WordPressKit + +// MARK: - WordPressAuthenticator Configuration +// +public struct WordPressAuthenticatorConfiguration { + + /// WordPress.com Client ID + /// + let wpcomClientId: String + + /// WordPress.com Secret + /// + let wpcomSecret: String + + /// Client App: Used for Magic Link purposes. + /// + let wpcomScheme: String + + /// WordPress.com Terms of Service URL + /// + let wpcomTermsOfServiceURL: URL + + /// WordPress.com Base URL for OAuth + /// + let wpcomBaseURL: URL + + /// WordPress.com API Base URL + /// + let wpcomAPIBaseURL: URL + + /// The URL of a webpage which has details about What is WordPress.com?. + /// + /// Displayed in the WordPress.com login page. The button/link will not be displayed if this value is nil. + /// + let whatIsWPComURL: URL? + + /// GoogleLogin Client ID + /// + let googleLoginClientId: String + + /// GoogleLogin ServerClient ID + /// + let googleLoginServerClientId: String + + /// GoogleLogin Callback Scheme + /// + let googleLoginScheme: String + + internal var googleClientId: GoogleClientId { + guard let clientId = GoogleClientId(string: googleLoginClientId) else { + fatalError("Could not init GoogleClientId from developer provided value.") + } + + return clientId + } + + /// UserAgent + /// + let userAgent: String + + /// Flag indicating which Log In flow to display. + /// If enabled, when Log In is selected, a button view is displayed with options. + /// If disabled, when Log In is selected, the email login view is displayed with alternative options. + /// + let showLoginOptions: Bool + + /// Flag indicating if Sign Up UX is enabled for all services. + /// + let enableSignUp: Bool + + /// Hint buttons help users complete a step in the unified auth flow. Enabled by default. + /// If enabled, "Find your site address", "Reset your password", and others will be displayed. + /// If disabled, none of the hint buttons will appear on the unified auth flows. + let displayHintButtons: Bool + + /// Flag indicating if the Sign In With Apple option should be displayed. + /// + let enableSignInWithApple: Bool + + /// Flag indicating if signing up via Google is enabled. + /// This only applies to the unified Google flow. + /// When a user attempts to log in with a nonexistent account: + /// If enabled, the user will be redirected to Google signup. + /// If disabled, a view is displayed providing the user with other options. + /// + let enableSignupWithGoogle: Bool + + /// Flag for the unified login/signup flows. + /// If disabled, none of the unified flows will display. + /// If enabled, all unified flows will display. + /// + let enableUnifiedAuth: Bool + + /// Flag for the new prologue carousel. + /// If disabled, displays the old carousel. + /// If enabled, displays the new carousel. + let enableUnifiedCarousel: Bool + + /// Flag for the Passkeys, or WebAuthn, login. + let enablePasskeys: Bool + + /// Flag for the unified login/signup flows. + /// If disabled, the "Continue With WordPress" button in the login prologue is shown first. + /// If enabled, the "Enter your existing site address" button in the login prologue is shown first. + /// Default value is disabled + let continueWithSiteAddressFirst: Bool + + /// If enabled shows a "Sign in with site credentials" button in `GetStartedViewController` when landing in the screen after entering site address + /// Used to enable sign-in to self-hosted sites using WordPress.org credentials. + /// Disabled by default + let enableSiteCredentialsLoginForSelfHostedSites: Bool + + /// If enabled, we will ask for WPCOM login after signing in using .org site credentials. + /// Disabled by default + let isWPComLoginRequiredForSiteCredentialsLogin: Bool + + /// If enabled, a magic link is sent automatically in place of password then fall back to password. + /// If disabled, password is shown by default with an option to send a magic link. + let isWPComMagicLinkPreferredToPassword: Bool + + /// If enabled, the alternative magic link action on the password screen is shown as a secondary call-to-action at the bottom. + /// If disabled, the alternative magic link action on the password screen is shown below the reset password action. + let isWPComMagicLinkShownAsSecondaryActionOnPasswordScreen: Bool + + /// If enabled, the Prologue screen will display only the entry point for WPCom login and no site address login. + /// + let enableWPComLoginOnlyInPrologue: Bool + + /// If enabled, an entry point to the site creation flow will be added to the bottom button of the prologue screen of simplified login. + /// + let enableSiteCreation: Bool + + /// If enabled, social login will be display at the bottom of the WPCom login screen. + /// + let enableSocialLogin: Bool + + /// If enabled, there will be a border around the email label on the WPCom password screen. + /// + let emphasizeEmailForWPComPassword: Bool + + /// The optional instructions for WPCom password. + /// + let wpcomPasswordInstructions: String? + + /// If enabled, site discovery will not check for XMLRPC URL. + /// + let skipXMLRPCCheckForSiteDiscovery: Bool + + /// If enabled, site address login will not check for XMLRPC URL. + /// + let skipXMLRPCCheckForSiteAddressLogin: Bool + + /// If enabled, the library will trigger the delegate method `handleSiteCredentialLogin` + /// instead of using the XMLRPC API for handling site credential login. + let enableManualSiteCredentialLogin: Bool + + /// If enabled, the library will not show any alert or inline error message + /// when site credential login fails. + /// Instead, the delegate method `handleSiteCredentialLoginFailure` will be called. + /// + let enableManualErrorHandlingForSiteCredentialLogin: Bool + + /// Used to determine the `step` value for `unified_login_step` analytics event in `GetStartedViewController` + /// + /// - If disabled `start` will be used as `step` value + /// - Disabled by default + /// - If enabled, `enter_email_address` will be used as `step` value + /// - Custom step value is used because `start` is used in other VCs as well, which doesn't allow us to differentiate between screens. + /// - i.e. Some screens have the same `step` and `flow` value. `GetStartedViewController` and `SiteAddressViewController` for example. + /// + let useEnterEmailAddressAsStepValueForGetStartedVC: Bool + + /// If enabled, the prologue screen should hide the WPCom login CTA and show only the entry point to site address login. + /// + let enableSiteAddressLoginOnlyInPrologue: Bool + + /// If enabled, the prologue screen would display a link for site creation guide. + /// + let enableSiteCreationGuide: Bool + + /// Designated Initializer + /// + public init (wpcomClientId: String, + wpcomSecret: String, + wpcomScheme: String, + wpcomTermsOfServiceURL: URL, + wpcomBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultBaseURL, + wpcomAPIBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseURL, + whatIsWPComURL: URL? = nil, + googleLoginClientId: String, + googleLoginServerClientId: String, + googleLoginScheme: String, + userAgent: String, + showLoginOptions: Bool = false, + enableSignUp: Bool = true, + enableSignInWithApple: Bool = false, + enableSignupWithGoogle: Bool = false, + enableUnifiedAuth: Bool = false, + enableUnifiedCarousel: Bool = false, + enablePasskeys: Bool = true, + displayHintButtons: Bool = true, + continueWithSiteAddressFirst: Bool = false, + enableSiteCredentialsLoginForSelfHostedSites: Bool = false, + isWPComLoginRequiredForSiteCredentialsLogin: Bool = false, + isWPComMagicLinkPreferredToPassword: Bool = false, + isWPComMagicLinkShownAsSecondaryActionOnPasswordScreen: Bool = false, + enableWPComLoginOnlyInPrologue: Bool = false, + enableSiteCreation: Bool = false, + enableSocialLogin: Bool = false, + emphasizeEmailForWPComPassword: Bool = false, + wpcomPasswordInstructions: String? = nil, + skipXMLRPCCheckForSiteDiscovery: Bool = false, + skipXMLRPCCheckForSiteAddressLogin: Bool = false, + enableManualSiteCredentialLogin: Bool = false, + enableManualErrorHandlingForSiteCredentialLogin: Bool = false, + useEnterEmailAddressAsStepValueForGetStartedVC: Bool = false, + enableSiteAddressLoginOnlyInPrologue: Bool = false, + enableSiteCreationGuide: Bool = false + ) { + + self.wpcomClientId = wpcomClientId + self.wpcomSecret = wpcomSecret + self.wpcomScheme = wpcomScheme + self.wpcomTermsOfServiceURL = wpcomTermsOfServiceURL + self.wpcomBaseURL = wpcomBaseURL + self.wpcomAPIBaseURL = wpcomAPIBaseURL + self.whatIsWPComURL = whatIsWPComURL + self.googleLoginClientId = googleLoginClientId + self.googleLoginServerClientId = googleLoginServerClientId + self.googleLoginScheme = googleLoginScheme + self.userAgent = userAgent + self.showLoginOptions = showLoginOptions + self.enableSignUp = enableSignUp + self.enableSignInWithApple = enableSignInWithApple + self.enableUnifiedAuth = enableUnifiedAuth + self.enableUnifiedCarousel = enableUnifiedCarousel + self.enablePasskeys = enablePasskeys + self.displayHintButtons = displayHintButtons + self.enableSignupWithGoogle = enableSignupWithGoogle + self.continueWithSiteAddressFirst = continueWithSiteAddressFirst + self.enableSiteCredentialsLoginForSelfHostedSites = enableSiteCredentialsLoginForSelfHostedSites + self.isWPComLoginRequiredForSiteCredentialsLogin = isWPComLoginRequiredForSiteCredentialsLogin + self.isWPComMagicLinkPreferredToPassword = isWPComMagicLinkPreferredToPassword + self.isWPComMagicLinkShownAsSecondaryActionOnPasswordScreen = isWPComMagicLinkShownAsSecondaryActionOnPasswordScreen + self.enableWPComLoginOnlyInPrologue = enableWPComLoginOnlyInPrologue + self.enableSiteCreation = enableSiteCreation + self.enableSocialLogin = enableSocialLogin + self.emphasizeEmailForWPComPassword = emphasizeEmailForWPComPassword + self.wpcomPasswordInstructions = wpcomPasswordInstructions + self.skipXMLRPCCheckForSiteDiscovery = skipXMLRPCCheckForSiteDiscovery + self.skipXMLRPCCheckForSiteAddressLogin = skipXMLRPCCheckForSiteAddressLogin + self.enableManualSiteCredentialLogin = enableManualSiteCredentialLogin + self.enableManualErrorHandlingForSiteCredentialLogin = enableManualErrorHandlingForSiteCredentialLogin + self.useEnterEmailAddressAsStepValueForGetStartedVC = useEnterEmailAddressAsStepValueForGetStartedVC + self.enableSiteAddressLoginOnlyInPrologue = enableSiteAddressLoginOnlyInPrologue + self.enableSiteCreationGuide = enableSiteCreationGuide + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDelegateProtocol.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDelegateProtocol.swift new file mode 100644 index 000000000000..3282b931c164 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDelegateProtocol.swift @@ -0,0 +1,199 @@ +// MARK: - WordPressAuthenticator Delegate Protocol +// +public protocol WordPressAuthenticatorDelegate: AnyObject { + + /// Indicates if the active Authenticator can be dismissed, or not. + /// + var dismissActionEnabled: Bool { get } + + /// Indicates if the Support button action should be enabled, or not. + /// + var supportActionEnabled: Bool { get } + + /// Indicates if the WordPress.com's Terms of Service should be enabled, or not. + /// + var wpcomTermsOfServiceEnabled: Bool { get } + + /// Indicates if the Support notification indicator should be displayed. + /// + var showSupportNotificationIndicator: Bool { get } + + /// Indicates if Support is available or not. + /// + var supportEnabled: Bool { get } + + /// Returns true if there isn't a default WordPress.com account connected in the app. + var allowWPComLogin: Bool { get } + + /// Signals the Host App that a new WordPress.com account has just been created. + /// + /// - Parameters: + /// - username: WordPress.com Username. + /// - authToken: WordPress.com Bearer Token. + /// + func createdWordPressComAccount(username: String, authToken: String) + + /// Signals the Host App that the user has successfully authenticated with an Apple account. + /// + /// - Parameters: + /// - appleUserID: User ID received in the Apple credentials. + /// + func userAuthenticatedWithAppleUserID(_ appleUserID: String) + + /// Presents the Support new request, from a given ViewController, with a specified SourceTag. + /// + func presentSupportRequest(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag) + + /// Signals to the Host App that a WordPress site is available and needs validated + /// before presenting the username and password view controller. + /// - Parameters: + /// - site: passes in the site information to the delegate method. + /// - onCompletion: Closure to be executed on completion. + /// + func shouldPresentUsernamePasswordController(for siteInfo: WordPressComSiteInfo?, onCompletion: @escaping (WordPressAuthenticatorResult) -> Void) + + /// Presents the Login Epilogue, in the specified NavigationController. + /// + /// - Parameters: + /// - navigationController: navigation stack for any epilogue views to be shown on. + /// - credentials: WPCOM or WPORG credentials. + /// - source: an optional identifier of the login flow, can be from the login prologue or provided by the host app. + /// - onDismiss: called when the auth flow is dismissed. + func presentLoginEpilogue(in navigationController: UINavigationController, for credentials: AuthenticatorCredentials, source: SignInSource?, onDismiss: @escaping () -> Void) + + /// Presents the Login Epilogue, in the specified NavigationController. + /// + func presentSignupEpilogue( + in navigationController: UINavigationController, + for credentials: AuthenticatorCredentials, + socialUser: SocialUser? + ) + + /// Presents the Support Interface from a given ViewController. + /// + /// - Parameters: + /// - from: ViewController from which to present the support interface from + /// - sourceTag: Support source tag of the view controller. + /// - lastStep: Last `Step` tracked in `AuthenticatorAnalyticsTracker` + /// - lastFlow: Last `Flow` tracked in `AuthenticatorAnalyticsTracker` + /// + func presentSupport(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag, lastStep: AuthenticatorAnalyticsTracker.Step, lastFlow: AuthenticatorAnalyticsTracker.Flow) + + /// Indicates if the Login Epilogue should be displayed. + /// + /// - Parameter isJetpackLogin: Indicates if we've just logged into a WordPress.com account for Jetpack purposes!. + /// + func shouldPresentLoginEpilogue(isJetpackLogin: Bool) -> Bool + + /// Indicates the Host app wants to handle and display a given error. + /// + func shouldHandleError(_ error: Error) -> Bool + + /// Signals the Host app that there is an error that needs to be handled. + /// + func handleError(_ error: Error, onCompletion: @escaping (UIViewController) -> Void) + + /// Indicates if the Signup Epilogue should be displayed. + /// + func shouldPresentSignupEpilogue() -> Bool + + /// Signals the Host App that a WordPress Site (wpcom or wporg) is available with the specified credentials. + /// + /// - Parameters: + /// - credentials: WordPress Site Credentials. + /// - onCompletion: Closure to be executed on completion. + /// + func sync(credentials: AuthenticatorCredentials, onCompletion: @escaping () -> Void) + + /// Signals to the Host App that a WordPress site is available and needs validated. + /// This method is only triggered in the site discovery flow. + /// + /// - Parameters: + /// - siteInfo: The fetched site information - can be nil the site doesn't exist or have WordPress + /// - navigationController: the current navigation stack of the site discovery flow. + /// + func troubleshootSite(_ siteInfo: WordPressComSiteInfo?, in navigationController: UINavigationController?) + + /// Sends site credentials to the host app so that it can handle login locally. + /// This method is only triggered when the config `skipXMLRPCCheckForSiteAddressLogin` is enabled. + /// + /// - Parameters: + /// - credentials: WordPress.org credentials submitted in the site credentials form. + /// - onLoading: the block to update the loading state on the site credentials form when necessary. + /// - onSuccess: the block to finish the login flow after login succeeds. + /// - onFailure: the block to trigger error handling. The closure accepts an error and a boolean indicating if the login failed with incorrect credentials. + /// + func handleSiteCredentialLogin(credentials: WordPressOrgCredentials, + onLoading: @escaping (Bool) -> Void, + onSuccess: @escaping () -> Void, + onFailure: @escaping (Error, Bool) -> Void) + + /// Signals to the Host App to handle an error for site credential login. + /// + /// - Parameters: + /// - error: The site credential login failure. + /// - siteURL: The site URL of the login failure. + /// - viewController: the view controller containing the site credential input. + /// + func handleSiteCredentialLoginFailure(error: Error, + for siteURL: String, + in viewController: UIViewController) + + /// Signals to the Host App to navigate to the site creation flow. + /// This method is currently used only in the simplified login flow + /// when the configs `enableSimplifiedLoginI1` and `enableSiteCreationForSimplifiedLoginI1` is enabled + /// + /// - Parameters: + /// - navigationController: the current navigation stack of the login flow. + /// + func showSiteCreation(in navigationController: UINavigationController) + + /// Signals to the Host App to navigate to the site creation guide. + /// This method triggered only if `enableSiteCreationGuide` config is enabled. + /// + /// - Parameters: + /// - navigationController: the current navigation stack of the login flow. + /// + func showSiteCreationGuide(in navigationController: UINavigationController) + + /// Signals the Host App that a given Analytics Event has occurred. + /// + func track(event: WPAnalyticsStat) + + /// Signals the Host App that a given Analytics Event (with the specified properties) has occurred. + /// + func track(event: WPAnalyticsStat, properties: [AnyHashable: Any]) + + /// Signals the Host App that a given Analytics Event (with an associated Error) has occurred. + /// + func track(event: WPAnalyticsStat, error: Error) +} + +/// Extension with default implementation for optional delegate methods. +/// +public extension WordPressAuthenticatorDelegate { + func troubleshootSite(_ siteInfo: WordPressComSiteInfo?, in navigationController: UINavigationController?) { + // No-op + } + + func showSiteCreation(in navigationController: UINavigationController) { + // No-op + } + + func showSiteCreationGuide(in navigationController: UINavigationController) { + // No-op + } + + func handleSiteCredentialLogin(credentials: WordPressOrgCredentials, + onLoading: @escaping (Bool) -> Void, + onSuccess: @escaping () -> Void, + onFailure: @escaping (Error, Bool) -> Void) { + // No-op + } + + func handleSiteCredentialLoginFailure(error: Error, + for siteURL: String, + in viewController: UIViewController) { + // No-op + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayImages.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayImages.swift new file mode 100644 index 000000000000..83d55452376b --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayImages.swift @@ -0,0 +1,22 @@ +// MARK: - WordPress Authenticator Display Images +// +public struct WordPressAuthenticatorDisplayImages { + public let magicLink: UIImage + public let siteAddressModalPlaceholder: UIImage + + /// Designated initializer. + /// + public init(magicLink: UIImage, siteAddressModalPlaceholder: UIImage) { + self.magicLink = magicLink + self.siteAddressModalPlaceholder = siteAddressModalPlaceholder + } +} + +public extension WordPressAuthenticatorDisplayImages { + static var defaultImages: WordPressAuthenticatorDisplayImages { + return WordPressAuthenticatorDisplayImages( + magicLink: .magicLinkImage, + siteAddressModalPlaceholder: .siteAddressModalPlaceholder + ) + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayStrings.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayStrings.swift new file mode 100644 index 000000000000..077fd52a6c17 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorDisplayStrings.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - WordPress Authenticator Display Strings +// +public struct WordPressAuthenticatorDisplayStrings { + /// Strings: Login instructions. + /// + public let emailLoginInstructions: String + public let getStartedInstructions: String + public let jetpackLoginInstructions: String + public let siteLoginInstructions: String + public let siteCredentialInstructions: String + public let usernamePasswordInstructions: String + public let twoFactorInstructions: String + public let twoFactorOtherFormsInstructions: String + public let magicLinkSignupInstructions: String + public let openMailSignupInstructions: String + public let openMailLoginInstructions: String + public let verifyMailLoginInstructions: String + public let alternativelyEnterPasswordInstructions: String + public let checkSpamInstructions: String + public let oopsInstructions: String + public let googleSignupInstructions: String + public let googlePasswordInstructions: String + public let applePasswordInstructions: String + + /// Strings: primary call-to-action button titles. + /// + public let continueButtonTitle: String + public let magicLinkButtonTitle: String + public let openMailButtonTitle: String + public let createAccountButtonTitle: String + public let continueWithWPButtonTitle: String + public let enterYourSiteAddressButtonTitle: String + public let signInWithSiteCredentialsButtonTitle: String + public let sendEmailVerificationLinkButtonTitle: String + public let loginWithAccountPasswordButtonTitle: String + + /// Large titles displayed in unified auth flows. + /// + public let getStartedTitle: String + public let logInTitle: String + public let signUpTitle: String + public let waitingForGoogleTitle: String + + /// Strings: secondary call-to-action button titles. + /// + public let findSiteButtonTitle: String + public let resetPasswordButtonTitle: String + public let getLoginLinkButtonTitle: String + public let textCodeButtonTitle: String + public let securityKeyButtonTitle: String + public let loginTermsOfService: String + public let signupTermsOfService: String + public let whatIsWPComLinkTitle: String + public let siteCreationButtonTitle: String + public let siteCreationGuideButtonTitle: String + + /// Placeholder text for textfields. + /// + public let usernamePlaceholder: String + public let passwordPlaceholder: String + public let siteAddressPlaceholder: String + public let twoFactorCodePlaceholder: String + public let emailAddressPlaceholder: String + + /// Designated initializer. + /// + public init(emailLoginInstructions: String = defaultStrings.emailLoginInstructions, + getStartedInstructions: String = defaultStrings.getStartedInstructions, + jetpackLoginInstructions: String = defaultStrings.jetpackLoginInstructions, + siteLoginInstructions: String = defaultStrings.siteLoginInstructions, + siteCredentialInstructions: String = defaultStrings.siteCredentialInstructions, + usernamePasswordInstructions: String = defaultStrings.usernamePasswordInstructions, + twoFactorInstructions: String = defaultStrings.twoFactorInstructions, + twoFactorOtherFormsInstructions: String = defaultStrings.twoFactorOtherFormsInstructions, + magicLinkSignupInstructions: String = defaultStrings.magicLinkSignupInstructions, + openMailSignupInstructions: String = defaultStrings.openMailSignupInstructions, + openMailLoginInstructions: String = defaultStrings.openMailLoginInstructions, + verifyMailLoginInstructions: String = defaultStrings.verifyMailLoginInstructions, + alternativelyEnterPasswordInstructions: String = defaultStrings.alternativelyEnterPasswordInstructions, + checkSpamInstructions: String = defaultStrings.checkSpamInstructions, + oopsInstructions: String = defaultStrings.oopsInstructions, + googleSignupInstructions: String = defaultStrings.googleSignupInstructions, + googlePasswordInstructions: String = defaultStrings.googlePasswordInstructions, + applePasswordInstructions: String = defaultStrings.applePasswordInstructions, + continueButtonTitle: String = defaultStrings.continueButtonTitle, + magicLinkButtonTitle: String = defaultStrings.magicLinkButtonTitle, + openMailButtonTitle: String = defaultStrings.openMailButtonTitle, + createAccountButtonTitle: String = defaultStrings.createAccountButtonTitle, + continueWithWPButtonTitle: String = defaultStrings.continueWithWPButtonTitle, + enterYourSiteAddressButtonTitle: String = defaultStrings.enterYourSiteAddressButtonTitle, + signInWithSiteCredentialsButtonTitle: String = defaultStrings.signInWithSiteCredentialsButtonTitle, + sendEmailVerificationLinkButtonTitle: String = defaultStrings.sendEmailVerificationLinkButtonTitle, + loginWithAccountPasswordButtonTitle: String = defaultStrings.loginWithAccountPasswordButtonTitle, + findSiteButtonTitle: String = defaultStrings.findSiteButtonTitle, + resetPasswordButtonTitle: String = defaultStrings.resetPasswordButtonTitle, + getLoginLinkButtonTitle: String = defaultStrings.getLoginLinkButtonTitle, + textCodeButtonTitle: String = defaultStrings.textCodeButtonTitle, + securityKeyButtonTitle: String = defaultStrings.securityKeyButtonTitle, + loginTermsOfService: String = defaultStrings.loginTermsOfService, + signupTermsOfService: String = defaultStrings.signupTermsOfService, + whatIsWPComLinkTitle: String = defaultStrings.whatIsWPComLinkTitle, + siteCreationButtonTitle: String = defaultStrings.siteCreationButtonTitle, + getStartedTitle: String = defaultStrings.getStartedTitle, + logInTitle: String = defaultStrings.logInTitle, + signUpTitle: String = defaultStrings.signUpTitle, + waitingForGoogleTitle: String = defaultStrings.waitingForGoogleTitle, + usernamePlaceholder: String = defaultStrings.usernamePlaceholder, + passwordPlaceholder: String = defaultStrings.passwordPlaceholder, + siteAddressPlaceholder: String = defaultStrings.siteAddressPlaceholder, + twoFactorCodePlaceholder: String = defaultStrings.twoFactorCodePlaceholder, + emailAddressPlaceholder: String = defaultStrings.emailAddressPlaceholder, + siteCreationGuideButtonTitle: String = defaultStrings.siteCreationGuideButtonTitle) { + self.emailLoginInstructions = emailLoginInstructions + self.getStartedInstructions = getStartedInstructions + self.jetpackLoginInstructions = jetpackLoginInstructions + self.siteLoginInstructions = siteLoginInstructions + self.siteCredentialInstructions = siteCredentialInstructions + self.usernamePasswordInstructions = usernamePasswordInstructions + self.twoFactorInstructions = twoFactorInstructions + self.twoFactorOtherFormsInstructions = twoFactorOtherFormsInstructions + self.magicLinkSignupInstructions = magicLinkSignupInstructions + self.openMailSignupInstructions = openMailSignupInstructions + self.openMailLoginInstructions = openMailLoginInstructions + self.verifyMailLoginInstructions = verifyMailLoginInstructions + self.alternativelyEnterPasswordInstructions = alternativelyEnterPasswordInstructions + self.checkSpamInstructions = checkSpamInstructions + self.oopsInstructions = oopsInstructions + self.googleSignupInstructions = googleSignupInstructions + self.googlePasswordInstructions = googlePasswordInstructions + self.applePasswordInstructions = applePasswordInstructions + self.continueButtonTitle = continueButtonTitle + self.magicLinkButtonTitle = magicLinkButtonTitle + self.openMailButtonTitle = openMailButtonTitle + self.createAccountButtonTitle = createAccountButtonTitle + self.continueWithWPButtonTitle = continueWithWPButtonTitle + self.enterYourSiteAddressButtonTitle = enterYourSiteAddressButtonTitle + self.signInWithSiteCredentialsButtonTitle = signInWithSiteCredentialsButtonTitle + self.sendEmailVerificationLinkButtonTitle = sendEmailVerificationLinkButtonTitle + self.loginWithAccountPasswordButtonTitle = loginWithAccountPasswordButtonTitle + self.findSiteButtonTitle = findSiteButtonTitle + self.resetPasswordButtonTitle = resetPasswordButtonTitle + self.getLoginLinkButtonTitle = getLoginLinkButtonTitle + self.textCodeButtonTitle = textCodeButtonTitle + self.securityKeyButtonTitle = securityKeyButtonTitle + self.loginTermsOfService = loginTermsOfService + self.signupTermsOfService = signupTermsOfService + self.whatIsWPComLinkTitle = whatIsWPComLinkTitle + self.siteCreationButtonTitle = siteCreationButtonTitle + self.getStartedTitle = getStartedTitle + self.logInTitle = logInTitle + self.signUpTitle = signUpTitle + self.waitingForGoogleTitle = waitingForGoogleTitle + self.usernamePlaceholder = usernamePlaceholder + self.passwordPlaceholder = passwordPlaceholder + self.siteAddressPlaceholder = siteAddressPlaceholder + self.twoFactorCodePlaceholder = twoFactorCodePlaceholder + self.emailAddressPlaceholder = emailAddressPlaceholder + self.siteCreationGuideButtonTitle = siteCreationGuideButtonTitle + } +} + +public extension WordPressAuthenticatorDisplayStrings { + static var defaultStrings: WordPressAuthenticatorDisplayStrings { + return WordPressAuthenticatorDisplayStrings( + emailLoginInstructions: NSLocalizedString("Log in to your WordPress.com account with your email address.", + comment: "Instruction text on the login's email address screen."), + getStartedInstructions: NSLocalizedString("Enter your email address to log in or create a WordPress.com account.", + comment: "Instruction text on the initial email address entry screen."), + jetpackLoginInstructions: NSLocalizedString("Log in to the WordPress.com account you used to connect Jetpack.", + comment: "Instruction text on the login's email address screen."), + siteLoginInstructions: NSLocalizedString("Enter the address of the WordPress site you'd like to connect.", + comment: "Instruction text on the login's site addresss screen."), + siteCredentialInstructions: NSLocalizedString("Enter your account information for %@.", + comment: "Enter your account information for {site url}. Asks the user to enter a username and password for their self-hosted site."), + usernamePasswordInstructions: NSLocalizedString("Log in with your WordPress.com username and password.", + comment: "Instructions on the WordPress.com username / password log in form."), + twoFactorInstructions: NSLocalizedString("Please enter the verification code from your authenticator app.", + comment: "Instruction text on the two-factor screen."), + twoFactorOtherFormsInstructions: NSLocalizedString("Or choose another form of authentication.", + comment: "Instruction text for other forms of two-factor auth methods."), + magicLinkSignupInstructions: NSLocalizedString("We'll email you a signup link to create your new WordPress.com account.", + comment: "Instruction text on the Sign Up screen."), + openMailSignupInstructions: NSLocalizedString("We've emailed you a signup link to create your new WordPress.com account. Check your email on this device, and tap the link in the email you receive from WordPress.com.", + comment: "Instruction text after a signup Magic Link was requested."), + openMailLoginInstructions: NSLocalizedString("Check your email on this device, and tap the link in the email you receive from WordPress.com.", + comment: "Instruction text after a login Magic Link was requested."), + verifyMailLoginInstructions: NSLocalizedString("A WordPress.com account is connected to your store credentials. To continue, we will send a verification link to the email address above.", + comment: "Instruction text to explain magic link login step."), + alternativelyEnterPasswordInstructions: NSLocalizedString("Alternatively, you may enter the password for this account.", + comment: "Instruction text to explain to help users type their password instead of using magic link login option."), + checkSpamInstructions: NSLocalizedString("Not seeing the email? Check your Spam or Junk Mail folder.", comment: "Instructions after a Magic Link was sent, but the email can't be found in their inbox."), + oopsInstructions: NSLocalizedString("Didn't mean to create a new account? Go back to re-enter your email address.", comment: "Instructions after a Magic Link was sent, but email is incorrect."), + googleSignupInstructions: NSLocalizedString("We'll use this email address to create your new WordPress.com account.", comment: "Text confirming email address to be used for new account."), + googlePasswordInstructions: NSLocalizedString("To proceed with this Google account, please first log in with your WordPress.com password. This will only be asked once.", + comment: "Instructional text shown when requesting the user's password for Google login."), + applePasswordInstructions: NSLocalizedString("To proceed with this Apple ID, please first log in with your WordPress.com password. This will only be asked once.", + comment: "Instructional text shown when requesting the user's password for Apple login."), + continueButtonTitle: NSLocalizedString("Continue", + comment: "The button title text when there is a next step for logging in or signing up."), + magicLinkButtonTitle: NSLocalizedString("Send Link by Email", + comment: "The button title text for sending a magic link."), + openMailButtonTitle: NSLocalizedString("Open Mail", + comment: "The button title text for opening the user's preferred email app."), + createAccountButtonTitle: NSLocalizedString("Create Account", + comment: "The button title text for creating a new account."), + continueWithWPButtonTitle: NSLocalizedString("Log in or sign up with WordPress.com", + comment: "Button title. Takes the user to the login by email flow."), + enterYourSiteAddressButtonTitle: NSLocalizedString("Enter your existing site address", + comment: "Button title. Takes the user to the login by site address flow."), + signInWithSiteCredentialsButtonTitle: NSLocalizedString("Sign in with site credentials", + comment: "Button title. Takes the user the Enter site credentials screen."), + sendEmailVerificationLinkButtonTitle: NSLocalizedString("Send email verification link", + comment: "Button title. Sends a email verification link (Magin link) for signing in."), + loginWithAccountPasswordButtonTitle: NSLocalizedString("Login with account password", + comment: "Button title. Takes the user to the Enter account password screen."), + findSiteButtonTitle: NSLocalizedString("Find your site address", + comment: "The hint button's title text to help users find their site address."), + resetPasswordButtonTitle: NSLocalizedString("Reset your password", + comment: "The button title for a secondary call-to-action button. When the user can't remember their password."), + getLoginLinkButtonTitle: NSLocalizedString("Get a login link by email", + comment: "The button title for a secondary call-to-action button. When the user wants to try sending a magic link instead of entering a password."), + textCodeButtonTitle: NSLocalizedString("Text me a code via SMS", + comment: "The button's title text to send a 2FA code via SMS text message."), + securityKeyButtonTitle: NSLocalizedString("Use a security key", + comment: "The button's title text to use a security key."), + loginTermsOfService: NSLocalizedString("By continuing, you agree to our _Terms of Service_.", comment: "Legal disclaimer for logging in. The underscores _..._ denote underline."), + signupTermsOfService: NSLocalizedString("If you continue with Apple or Google and don't already have a WordPress.com account, you are creating an account and you agree to our _Terms of Service_.", comment: "Legal disclaimer for signing up. The underscores _..._ denote underline."), + whatIsWPComLinkTitle: NSLocalizedString("What is WordPress.com?", + comment: "Navigates to page with details about What is WordPress.com."), + siteCreationButtonTitle: NSLocalizedString("Create a Site", + comment: "Navigates to a new flow for site creation."), + getStartedTitle: NSLocalizedString("Get Started", + comment: "View title for initial auth views."), + logInTitle: NSLocalizedString("Log In", + comment: "View title during the log in process."), + signUpTitle: NSLocalizedString("Sign Up", + comment: "View title during the sign up process."), + waitingForGoogleTitle: NSLocalizedString("Waiting...", + comment: "View title during the Google auth process."), + usernamePlaceholder: NSLocalizedString("Username", + comment: "Placeholder for the username textfield."), + passwordPlaceholder: NSLocalizedString("Password", + comment: "Placeholder for the password textfield."), + siteAddressPlaceholder: NSLocalizedString("example.com", + comment: "Placeholder for the site url textfield."), + twoFactorCodePlaceholder: NSLocalizedString("Authentication code", + comment: "Placeholder for the 2FA code textfield."), + emailAddressPlaceholder: NSLocalizedString("Email address", + comment: "Placeholder for the email address textfield."), + siteCreationGuideButtonTitle: NSLocalizedString( + "wordPressAuthenticatorDisplayStrings.default.siteCreationGuideButtonTitle", + value: "Starting a new site?", + comment: "Title for the link for site creation guide." + ) + ) + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorResult.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorResult.swift new file mode 100644 index 000000000000..e8497064271a --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorResult.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Provides options for clients of WordPressAuthenticator +/// to signal what they expect WPAuthenticator to do in response to +/// `shouldPresentUsernamePasswordController` +/// +/// @see WordPressAuthenticatorDelegate.shouldPresentUsernamePasswordController +public enum WordPressAuthenticatorResult { + + /// An error + /// + case error(value: Error) + + /// Boolean flag to indicate if UI providing entry for username and passsword + /// should be presented + /// + case presentPasswordController(value: Bool) + + /// Present the view controller requesting the email address + /// associated to the user's wordpress.com account + /// + case presentEmailController + + /// A view controller to be inserted into the navigation stack + /// + case injectViewController(value: UIViewController) +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorStyles.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorStyles.swift new file mode 100644 index 000000000000..3d9214100967 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressAuthenticatorStyles.swift @@ -0,0 +1,305 @@ +import UIKit +import Gridicons +import WordPressShared + +// MARK: - WordPress Authenticator Styles +// +public struct WordPressAuthenticatorStyle { + /// Style: Primary + Normal State + /// + public let primaryNormalBackgroundColor: UIColor + + public let primaryNormalBorderColor: UIColor? + + /// Style: Primary + Highlighted State + /// + public let primaryHighlightBackgroundColor: UIColor + + public let primaryHighlightBorderColor: UIColor? + + /// Style: Secondary + /// + public let secondaryNormalBackgroundColor: UIColor + + public let secondaryNormalBorderColor: UIColor + + public let secondaryHighlightBackgroundColor: UIColor + + public let secondaryHighlightBorderColor: UIColor + + /// Style: Disabled State + /// + public let disabledBackgroundColor: UIColor + + public let disabledBorderColor: UIColor + + public let primaryTitleColor: UIColor + + public let secondaryTitleColor: UIColor + + public let disabledTitleColor: UIColor + + /// Color of the spinner that is shown when a button is disabled. + public let disabledButtonActivityIndicatorColor: UIColor + + /// Style: Text Buttons + /// + public let textButtonColor: UIColor + + public let textButtonHighlightColor: UIColor + + /// Style: Labels + /// + public let instructionColor: UIColor + + public let subheadlineColor: UIColor + + public let placeholderColor: UIColor + + /// Style: Login screen background colors + /// + public let viewControllerBackgroundColor: UIColor + + public let textFieldBackgroundColor: UIColor + + // If not specified, falls back to viewControllerBackgroundColor. + public let buttonViewBackgroundColor: UIColor + + /// Style: shadow image view on top of the button view like a divider. + /// If not specified, falls back to image "darkgrey-shadow". + /// + public let buttonViewTopShadowImage: UIImage? + + /// Style: nav bar + /// + public let navBarImage: UIImage + + public let navBarBadgeColor: UIColor + + public let navBarBackgroundColor: UIColor + + public let navButtonTextColor: UIColor + + /// Style: prologue background colors + /// + public let prologueBackgroundColor: UIColor + + /// Style: optional prologue background image + /// + public let prologueBackgroundImage: UIImage? + + /// Style: prologue background colors + /// + public let prologueTitleColor: UIColor + + /// Style: optional prologue buttons blur effect + public let prologueButtonsBlurEffect: UIBlurEffect? + + /// Style: primary button on the prologue view (continue) + /// When `nil` it will use the primary styles defined here + /// Defaults to `nil` + /// + public let prologuePrimaryButtonStyle: NUXButtonStyle? + + /// Style: secondary button on the prologue view (site address) + /// When `nil` it will use the secondary styles defined here + /// Defaults to `nil` + /// + public let prologueSecondaryButtonStyle: NUXButtonStyle? + + /// Style: prologue top container child view controller + /// When nil, `LoginProloguePageViewController` is displayed in the top container + /// + public let prologueTopContainerChildViewController: () -> UIViewController? + + /// Style: status bar style + /// + public let statusBarStyle: UIStatusBarStyle + + /// Style: OR divider separator color + /// + /// Used in `NUXStackedButtonsViewController` + /// + public let orDividerSeparatorColor: UIColor + + /// Style: OR divider text color + /// + /// Used in `NUXStackedButtonsViewController` + /// + public let orDividerTextColor: UIColor + + /// Designated initializer + /// + public init(primaryNormalBackgroundColor: UIColor, + primaryNormalBorderColor: UIColor?, + primaryHighlightBackgroundColor: UIColor, + primaryHighlightBorderColor: UIColor?, + secondaryNormalBackgroundColor: UIColor, + secondaryNormalBorderColor: UIColor, + secondaryHighlightBackgroundColor: UIColor, + secondaryHighlightBorderColor: UIColor, + disabledBackgroundColor: UIColor, + disabledBorderColor: UIColor, + primaryTitleColor: UIColor, + secondaryTitleColor: UIColor, + disabledTitleColor: UIColor, + disabledButtonActivityIndicatorColor: UIColor, + textButtonColor: UIColor, + textButtonHighlightColor: UIColor, + instructionColor: UIColor, + subheadlineColor: UIColor, + placeholderColor: UIColor, + viewControllerBackgroundColor: UIColor, + textFieldBackgroundColor: UIColor, + buttonViewBackgroundColor: UIColor? = nil, + buttonViewTopShadowImage: UIImage? = UIImage(named: "darkgrey-shadow"), + navBarImage: UIImage, + navBarBadgeColor: UIColor, + navBarBackgroundColor: UIColor, + navButtonTextColor: UIColor = .white, + prologueBackgroundColor: UIColor = WPStyleGuide.wordPressBlue(), + prologueBackgroundImage: UIImage? = nil, + prologueTitleColor: UIColor = .white, + prologueButtonsBlurEffect: UIBlurEffect? = nil, + prologuePrimaryButtonStyle: NUXButtonStyle? = nil, + prologueSecondaryButtonStyle: NUXButtonStyle? = nil, + prologueTopContainerChildViewController: @autoclosure @escaping () -> UIViewController? = nil, + statusBarStyle: UIStatusBarStyle = .lightContent, + orDividerSeparatorColor: UIColor = .tertiaryLabel, + orDividerTextColor: UIColor = .secondaryLabel) { + self.primaryNormalBackgroundColor = primaryNormalBackgroundColor + self.primaryNormalBorderColor = primaryNormalBorderColor + self.primaryHighlightBackgroundColor = primaryHighlightBackgroundColor + self.primaryHighlightBorderColor = primaryHighlightBorderColor + self.secondaryNormalBackgroundColor = secondaryNormalBackgroundColor + self.secondaryNormalBorderColor = secondaryNormalBorderColor + self.secondaryHighlightBackgroundColor = secondaryHighlightBackgroundColor + self.secondaryHighlightBorderColor = secondaryHighlightBorderColor + self.disabledBackgroundColor = disabledBackgroundColor + self.disabledBorderColor = disabledBorderColor + self.primaryTitleColor = primaryTitleColor + self.secondaryTitleColor = secondaryTitleColor + self.disabledTitleColor = disabledTitleColor + self.disabledButtonActivityIndicatorColor = disabledButtonActivityIndicatorColor + self.textButtonColor = textButtonColor + self.textButtonHighlightColor = textButtonHighlightColor + self.instructionColor = instructionColor + self.subheadlineColor = subheadlineColor + self.placeholderColor = placeholderColor + self.viewControllerBackgroundColor = viewControllerBackgroundColor + self.textFieldBackgroundColor = textFieldBackgroundColor + self.buttonViewBackgroundColor = buttonViewBackgroundColor ?? viewControllerBackgroundColor + self.buttonViewTopShadowImage = buttonViewTopShadowImage + self.navBarImage = navBarImage + self.navBarBadgeColor = navBarBadgeColor + self.navBarBackgroundColor = navBarBackgroundColor + self.navButtonTextColor = navButtonTextColor + self.prologueBackgroundColor = prologueBackgroundColor + self.prologueBackgroundImage = prologueBackgroundImage + self.prologueTitleColor = prologueTitleColor + self.prologueButtonsBlurEffect = prologueButtonsBlurEffect + self.prologuePrimaryButtonStyle = prologuePrimaryButtonStyle + self.prologueSecondaryButtonStyle = prologueSecondaryButtonStyle + self.prologueTopContainerChildViewController = prologueTopContainerChildViewController + self.statusBarStyle = statusBarStyle + self.orDividerSeparatorColor = orDividerSeparatorColor + self.orDividerTextColor = orDividerTextColor + } +} + +// MARK: - WordPress Unified Authenticator Styles +// +// Styles specifically for the unified auth flows. +// +public struct WordPressAuthenticatorUnifiedStyle { + + /// Style: Auth view border colors + /// + public let borderColor: UIColor + + /// Style Auth default error color + /// + public let errorColor: UIColor + + /// Style: Auth default text color + /// + public let textColor: UIColor + + /// Style: Auth subtle text color + /// + public let textSubtleColor: UIColor + + /// Style: Auth plain text button normal state color + /// + public let textButtonColor: UIColor + + /// Style: Auth plain text button highlight state color + /// + public let textButtonHighlightColor: UIColor + + /// Style: Auth view background colors + /// + public let viewControllerBackgroundColor: UIColor + + /// Style: Auth Prologue buttons background color + public let prologueButtonsBackgroundColor: UIColor + + /// Style: Auth Prologue view background color + public let prologueViewBackgroundColor: UIColor + + /// Style: optional auth Prologue view background image + public let prologueBackgroundImage: UIImage? + + /// Style: optional blur effect for the buttons view + public let prologueButtonsBlurEffect: UIBlurEffect? + + /// Style: Status bar style. Defaults to `default`. + /// + public let statusBarStyle: UIStatusBarStyle + + /// Style: Navigation bar. + /// + public let navBarBackgroundColor: UIColor + public let navButtonTextColor: UIColor + public let navTitleTextColor: UIColor + + /// Style: Text color to be used for email in `GravatarEmailTableViewCell` + /// + public let gravatarEmailTextColor: UIColor? + + /// Designated initializer + /// + public init(borderColor: UIColor, + errorColor: UIColor, + textColor: UIColor, + textSubtleColor: UIColor, + textButtonColor: UIColor, + textButtonHighlightColor: UIColor, + viewControllerBackgroundColor: UIColor, + prologueButtonsBackgroundColor: UIColor = .clear, + prologueViewBackgroundColor: UIColor? = nil, + prologueBackgroundImage: UIImage? = nil, + prologueButtonsBlurEffect: UIBlurEffect? = nil, + statusBarStyle: UIStatusBarStyle = .default, + navBarBackgroundColor: UIColor, + navButtonTextColor: UIColor, + navTitleTextColor: UIColor, + gravatarEmailTextColor: UIColor? = nil) { + self.borderColor = borderColor + self.errorColor = errorColor + self.textColor = textColor + self.textSubtleColor = textSubtleColor + self.textButtonColor = textButtonColor + self.textButtonHighlightColor = textButtonHighlightColor + self.viewControllerBackgroundColor = viewControllerBackgroundColor + self.prologueButtonsBackgroundColor = prologueButtonsBackgroundColor + self.prologueViewBackgroundColor = prologueViewBackgroundColor ?? viewControllerBackgroundColor + self.prologueBackgroundImage = prologueBackgroundImage + self.prologueButtonsBlurEffect = prologueButtonsBlurEffect + self.statusBarStyle = statusBarStyle + self.navBarBackgroundColor = navBarBackgroundColor + self.navButtonTextColor = navButtonTextColor + self.navTitleTextColor = navTitleTextColor + self.gravatarEmailTextColor = gravatarEmailTextColor + } +} diff --git a/WordPressAuthenticator/Sources/Authenticator/WordPressSupportSourceTag.swift b/WordPressAuthenticator/Sources/Authenticator/WordPressSupportSourceTag.swift new file mode 100644 index 000000000000..6df18dee9e76 --- /dev/null +++ b/WordPressAuthenticator/Sources/Authenticator/WordPressSupportSourceTag.swift @@ -0,0 +1,84 @@ +import Foundation + +// MARK: - Authentication Flow Event. Useful to relay internal Auth events over to activity trackers. +// +public struct WordPressSupportSourceTag { + public let name: String + public let origin: String? + + public init(name: String, origin: String? = nil) { + self.name = name + self.origin = origin + } +} + +func ==(lhs: WordPressSupportSourceTag, rhs: WordPressSupportSourceTag) -> Bool { + return lhs.name == rhs.name +} + +extension WordPressSupportSourceTag { + public static var generalLogin: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "generalLogin", origin: "origin:login-screen") + } + public static var jetpackLogin: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "jetpackLogin", origin: "origin:jetpack-login-screen") + } + public static var loginEmail: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginEmail", origin: "origin:login-email") + } + public static var loginApple: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginApple", origin: "origin:login-apple") + } + public static var login2FA: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "login2FA", origin: "origin:login-2fa") + } + public static var loginWebauthn: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginWebauthn", origin: "origin:login-webauthn") + } + public static var loginMagicLink: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginMagicLink", origin: "origin:login-magic-link") + } + public static var loginSiteAddress: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginSiteAddress", origin: "origin:login-site-address") + } + + /// For `VerifyEmailViewController` + public static var verifyEmailInstructions: WordPressSupportSourceTag { + WordPressSupportSourceTag(name: "verifyEmailInstructions", origin: "origin:login-site-address") + } + + public static var loginUsernamePassword: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginUsernamePassword", origin: "origin:login-username-password") + } + public static var loginWPComUsernamePassword: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginWPComUsernamePassword", origin: "origin:wpcom-login-username-password") + } + public static var loginWPComPassword: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "loginWPComPassword", origin: "origin:login-wpcom-password") + } + public static var wpComSignupEmail: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComSignupEmail", origin: "origin:wpcom-signup-email-entry") + } + public static var wpComSignup: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComSignup", origin: "origin:signup-screen") + } + public static var wpComSignupWaitingForGoogle: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComSignupWaitingForGoogle", origin: "origin:signup-waiting-for-google") + } + public static var wpComAuthWaitingForGoogle: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComAuthWaitingForGoogle", origin: "origin:auth-waiting-for-google") + } + public static var wpComAuthGoogleSignupConfirmation: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComAuthGoogleSignupConfirmation", origin: "origin:auth-google-signup-confirmation") + } + public static var wpComSignupMagicLink: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComSignupMagicLink", origin: "origin:signup-magic-link") + } + public static var wpComSignupApple: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComSignupApple", origin: "origin:signup-apple") + } + + public static var wpComLoginMagicLinkAutoRequested: WordPressSupportSourceTag { + return WordPressSupportSourceTag(name: "wpComLoginMagicLinkAutoRequested", origin: "origin:login-email") + } +} diff --git a/WordPressAuthenticator/Sources/Credentials/AuthenticatorCredentials.swift b/WordPressAuthenticator/Sources/Credentials/AuthenticatorCredentials.swift new file mode 100644 index 000000000000..0a9963e6118a --- /dev/null +++ b/WordPressAuthenticator/Sources/Credentials/AuthenticatorCredentials.swift @@ -0,0 +1,20 @@ +import Foundation + +// MARK: - Authenticator Credentials +// +public struct AuthenticatorCredentials { + /// WordPress.com credentials + /// + public let wpcom: WordPressComCredentials? + + /// Self-hosted site credentials + /// + public let wporg: WordPressOrgCredentials? + + /// Designated initializer + /// + public init(wpcom: WordPressComCredentials? = nil, wporg: WordPressOrgCredentials? = nil) { + self.wpcom = wpcom + self.wporg = wporg + } +} diff --git a/WordPressAuthenticator/Sources/Credentials/WordPressComCredentials.swift b/WordPressAuthenticator/Sources/Credentials/WordPressComCredentials.swift new file mode 100644 index 000000000000..205a42191344 --- /dev/null +++ b/WordPressAuthenticator/Sources/Credentials/WordPressComCredentials.swift @@ -0,0 +1,42 @@ +import Foundation + +// MARK: - WordPress.com Credentials +// +public struct WordPressComCredentials: Equatable { + + /// WordPress.com authentication token + /// + public let authToken: String + + /// Is this a Jetpack-connected site? + /// + public let isJetpackLogin: Bool + + /// Is 2-factor Authentication Enabled? + /// + public let multifactor: Bool + + /// The site address used during login + /// + public var siteURL: String + + private let wpComURL = "https://wordpress.com" + + /// Legacy initializer, for backwards compatibility + /// + public init(authToken: String, + isJetpackLogin: Bool, + multifactor: Bool, + siteURL: String = "https://wordpress.com") { + self.authToken = authToken + self.isJetpackLogin = isJetpackLogin + self.multifactor = multifactor + self.siteURL = !siteURL.isEmpty ? siteURL : wpComURL + } +} + +// MARK: - Equatable Conformance +// +public func ==(lhs: WordPressComCredentials, rhs: WordPressComCredentials) -> Bool { + return lhs.authToken == rhs.authToken && lhs.siteURL == rhs.siteURL +} diff --git a/WordPressAuthenticator/Sources/Credentials/WordPressOrgCredentials.swift b/WordPressAuthenticator/Sources/Credentials/WordPressOrgCredentials.swift new file mode 100644 index 000000000000..6d951c14cd7a --- /dev/null +++ b/WordPressAuthenticator/Sources/Credentials/WordPressOrgCredentials.swift @@ -0,0 +1,45 @@ +import Foundation + +// MARK: - WordPress.org (aka self-hosted site) Credentials +// +public struct WordPressOrgCredentials: Equatable { + /// Self-hosted login username. + /// The one used in the /wp-admin/ panel. + /// + public let username: String + + /// Self-hosted login password. + /// The one used in the /wp-admin/ panel. + /// + public let password: String + + /// The URL to reach the XMLRPC file. + /// e.g.: https://exmaple.com/xmlrpc.php + /// + public let xmlrpc: String + + /// Self-hosted site options + /// + public let options: [AnyHashable: Any] + + /// Designated initializer + /// + public init(username: String, password: String, xmlrpc: String, options: [AnyHashable: Any]) { + self.username = username + self.password = password + self.xmlrpc = xmlrpc + self.options = options + } + + /// Returns site URL by stripping "/xmlrpc.php" from `xmlrpc` String property + /// + public var siteURL: String { + xmlrpc.removingSuffix("/xmlrpc.php") + } +} + +// MARK: - Equatable Conformance +// +public func ==(lhs: WordPressOrgCredentials, rhs: WordPressOrgCredentials) -> Bool { + return lhs.username == rhs.username && lhs.password == rhs.password && lhs.xmlrpc == rhs.xmlrpc +} diff --git a/WordPressAuthenticator/Sources/Email Client Picker/AppSelector.swift b/WordPressAuthenticator/Sources/Email Client Picker/AppSelector.swift new file mode 100644 index 000000000000..92ada270a79b --- /dev/null +++ b/WordPressAuthenticator/Sources/Email Client Picker/AppSelector.swift @@ -0,0 +1,134 @@ +import MessageUI +import UIKit + +/// App selector that selects an app from a list and opens it +/// Note: it's a wrapper of UIAlertController (which cannot be sublcassed) +public class AppSelector { + // the action sheet that will contain the list of apps that can be called + let alertController: UIAlertController + + /// initializes the picker with a dictionary. Initialization will fail if an empty/invalid app list is passed + /// - Parameters: + /// - appList: collection of apps to be added to the selector + /// - defaultAction: default action, if not nil, will be the first element of the list + /// - sourceView: the sourceView to anchor the action sheet to + /// - urlHandler: object that handles app URL schemes; defaults to UIApplication.shared + public init?(with appList: [String: String], + defaultAction: UIAlertAction? = nil, + sourceView: UIView, + urlHandler: URLHandler = UIApplication.shared) { + /// inline method that builds a list of app calls to be inserted in the action sheet + func makeAlertActions(from appList: [String: String]) -> [UIAlertAction]? { + guard !appList.isEmpty else { + return nil + } + + var actions = [UIAlertAction]() + for (name, urlString) in appList { + guard let url = URL(string: urlString), urlHandler.canOpenURL(url) else { + continue + } + actions.append(UIAlertAction(title: AppSelectorTitles(rawValue: name)?.localized ?? name, style: .default) { _ in + urlHandler.open(url, options: [:], completionHandler: nil) + }) + } + + guard !actions.isEmpty else { + return nil + } + // sort the apps alphabetically + actions = actions.sorted { $0.title ?? "" < $1.title ?? "" } + actions.append(UIAlertAction(title: AppSelectorTitles.cancel.localized, style: .cancel, handler: nil)) + + if let action = defaultAction { + actions.insert(action, at: 0) + } + return actions + } + + guard let appCalls = makeAlertActions(from: appList) else { + return nil + } + + alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alertController.popoverPresentationController?.sourceView = sourceView + alertController.popoverPresentationController?.sourceRect = sourceView.bounds + appCalls.forEach { + alertController.addAction($0) + } + } +} + +/// Initializers for Email Picker +public extension AppSelector { + /// initializes the picker with a plist file in a specified bundle + convenience init?(with plistFile: String, + in bundle: Bundle, + defaultAction: UIAlertAction? = nil, + sourceView: UIView) { + + guard let plistPath = bundle.path(forResource: plistFile, ofType: "plist"), + let availableApps = NSDictionary(contentsOfFile: plistPath) as? [String: String] else { + return nil + } + self.init(with: availableApps, + defaultAction: defaultAction, + sourceView: sourceView) + } + + /// Convenience init for a picker that calls supported email clients apps, defined in EmailClients.plist + convenience init?(sourceView: UIView) { + guard let bundlePath = Bundle(for: type(of: self)) + .path(forResource: "WordPressAuthenticatorResources", ofType: "bundle"), + let wpAuthenticatorBundle = Bundle(path: bundlePath) else { + return nil + } + + let plistFile = "EmailClients" + var defaultAction: UIAlertAction? + + // if available, prepend apple mail + if MFMailComposeViewController.canSendMail(), let url = URL(string: "message://") { + defaultAction = UIAlertAction(title: AppSelectorTitles.appleMail.localized, style: .default) { _ in + UIApplication.shared.open(url) + } + } + self.init(with: plistFile, + in: wpAuthenticatorBundle, + defaultAction: defaultAction, + sourceView: sourceView) + } +} + +/// Localizable app selector titles +enum AppSelectorTitles: String { + case appleMail + case gmail + case airmail + case msOutlook + case spark + case yahooMail + case fastmail + case cancel + + var localized: String { + switch self { + case .appleMail: + return NSLocalizedString("Mail (Default)", comment: "Option to select the Apple Mail app when logging in with magic links") + case .gmail: + return NSLocalizedString("Gmail", comment: "Option to select the Gmail app when logging in with magic links") + case .airmail: + return NSLocalizedString("Airmail", comment: "Option to select the Airmail app when logging in with magic links") + case .msOutlook: + return NSLocalizedString("Microsoft Outlook", comment: "Option to select the Microsft Outlook app when logging in with magic links") + case .spark: + return NSLocalizedString("Spark", comment: "Option to select the Spark email app when logging in with magic links") + case .yahooMail: + return NSLocalizedString("Yahoo Mail", comment: "Option to select the Yahoo Mail app when logging in with magic links") + case .fastmail: + return NSLocalizedString("Fastmail", comment: "Option to select the Fastmail app when logging in with magic links") + case .cancel: + return NSLocalizedString("Cancel", comment: "Option to cancel the email app selection when logging in with magic links") + } + } +} diff --git a/WordPressAuthenticator/Sources/Email Client Picker/LinkMailPresenter.swift b/WordPressAuthenticator/Sources/Email Client Picker/LinkMailPresenter.swift new file mode 100644 index 000000000000..6d3c75f1885a --- /dev/null +++ b/WordPressAuthenticator/Sources/Email Client Picker/LinkMailPresenter.swift @@ -0,0 +1,48 @@ +import MessageUI + +/// Email picker presenter +public class LinkMailPresenter { + + private let emailAddress: String + + public init(emailAddress: String) { + self.emailAddress = emailAddress + } + + /// Presents the available mail clients in an action sheet. If none is available, + /// Falls back to Apple Mail and opens it. + /// If not even Apple Mail is available, presents an alert to check your email + /// - Parameters: + /// - viewController: the UIViewController that will present the action sheet + /// - appSelector: the app picker that contains the available clients. Nil if no clients are available + /// reads the supported email clients from EmailClients.plist + public func presentEmailClients(on viewController: UIViewController, + appSelector: AppSelector?) { + + guard let picker = appSelector else { + // fall back to Apple Mail if no other clients are installed + if MFMailComposeViewController.canSendMail(), let url = URL(string: "message://") { + UIApplication.shared.open(url) + } else { + showAlertToCheckEmail(on: viewController) + } + return + } + viewController.present(picker.alertController, animated: true) + } + + private func showAlertToCheckEmail(on viewController: UIViewController) { + let title = NSLocalizedString("Check your email!", + comment: "Alert title for check your email during logIn/signUp.") + + let message = String.localizedStringWithFormat(NSLocalizedString("We just emailed a link to %@. Please check your mail app and tap the link to log in.", + comment: "message to ask a user to check their email for a WordPress.com email"), emailAddress) + + let alertController = UIAlertController(title: title, + message: message, + preferredStyle: .alert) + alertController.addCancelActionWithTitle(NSLocalizedString("OK", + comment: "Button title. An acknowledgement of the message displayed in a prompt.")) + viewController.present(alertController, animated: true, completion: nil) + } +} diff --git a/WordPressAuthenticator/Sources/Email Client Picker/URLHandler.swift b/WordPressAuthenticator/Sources/Email Client Picker/URLHandler.swift new file mode 100644 index 000000000000..111bc169d9aa --- /dev/null +++ b/WordPressAuthenticator/Sources/Email Client Picker/URLHandler.swift @@ -0,0 +1,12 @@ +/// Generic type that handles URL Schemes +public protocol URLHandler { + /// checks if the specified URL can be opened + func canOpenURL(_ url: URL) -> Bool + /// opens the specified URL + func open(_ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey: Any], + completionHandler completion: ((Bool) -> Void)?) +} + +/// conforms UIApplication to URLHandler to allow dependency injection +extension UIApplication: URLHandler {} diff --git a/WordPressAuthenticator/Sources/Extensions/FancyAlertViewController+LoginError.swift b/WordPressAuthenticator/Sources/Extensions/FancyAlertViewController+LoginError.swift new file mode 100644 index 000000000000..494cd2e8f0fc --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/FancyAlertViewController+LoginError.swift @@ -0,0 +1,218 @@ +import UIKit +import wpxmlrpc +import SafariServices +import WordPressUI + +extension FancyAlertViewController { + private struct Strings { + static let titleText = NSLocalizedString("What's my site address?", comment: "Title of alert helping users understand their site address") + static let bodyText = NSLocalizedString("Your site address appears in the bar at the top of the screen when you visit your site in Safari.", comment: "Body text of alert helping users understand their site address") + static let OK = NSLocalizedString("OK", comment: "Ok button for dismissing alert helping users understand their site address") + static let moreHelp = NSLocalizedString("Need more help?", comment: "Title of the more help button on alert helping users understand their site address") + } + + typealias ButtonConfig = FancyAlertViewController.Config.ButtonConfig + + private static func defaultButton(onTap: (() -> ())? = nil) -> ButtonConfig { + return ButtonConfig(Strings.OK) { controller, _ in + controller.dismiss(animated: true, completion: { + onTap?() + }) + } + } + + static func siteAddressHelpController( + loginFields: LoginFields, + sourceTag: WordPressSupportSourceTag, + moreHelpTapped: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil) -> FancyAlertViewController { + + let moreHelpButton = ButtonConfig(Strings.moreHelp) { controller, _ in + controller.dismiss(animated: true) { + // Find the topmost view controller that we can present from + guard WordPressAuthenticator.shared.delegate?.supportEnabled == true, + let viewController = UIApplication.shared.delegate?.window??.topmostPresentedViewController + else { + return + } + + moreHelpTapped?() + WordPressAuthenticator.shared.delegate?.presentSupportRequest(from: viewController, sourceTag: sourceTag) + } + } + + let image = WordPressAuthenticator.shared.displayImages.siteAddressModalPlaceholder + + let okButton = ButtonConfig(Strings.OK) { controller, _ in + onDismiss?() + controller.dismiss(animated: true, completion: nil) + } + + let config = FancyAlertViewController.Config(titleText: Strings.titleText, + bodyText: Strings.bodyText, + headerImage: image, + dividerPosition: .top, + defaultButton: okButton, + cancelButton: nil, + moreInfoButton: moreHelpButton, + titleAccessoryButton: nil, + dismissAction: onDismiss) + + let controller = FancyAlertViewController.controllerWithConfiguration(configuration: config) + return controller + } + + // MARK: - Error Handling + + /// Get an alert for the specified error. + /// The view is configured differently depending on the kind of error. + /// + /// - Parameters: + /// - error: An NSError instance + /// - loginFields: A LoginFields instance. + /// - sourceTag: The sourceTag that is the context of the error. + /// + /// - Returns: A FancyAlertViewController instance. + /// + static func alertForError(_ originalError: Error, loginFields: LoginFields, sourceTag: WordPressSupportSourceTag) -> FancyAlertViewController { + let error = originalError as NSError + var message = error.localizedDescription + + WPAuthenticatorLogError(message) + + if sourceTag == .jetpackLogin && error.domain == WordPressAuthenticator.errorDomain && error.code == NSURLErrorBadURL { + if WordPressAuthenticator.shared.delegate?.supportEnabled == true { + // TODO: Placeholder Jetpack login error message. Needs updating with final wording. 2017-06-15 Aerych. + message = NSLocalizedString("We're not able to connect to the Jetpack site at that URL. Contact us for assistance.", comment: "Error message shown when having trouble connecting to a Jetpack site.") + return alertForGenericErrorMessageWithHelpButton(message, loginFields: loginFields, sourceTag: sourceTag) + } + } + + if error.domain != WPXMLRPCFaultErrorDomain && error.code != NSURLErrorBadURL { + if WordPressAuthenticator.shared.delegate?.supportEnabled == true { + return alertForGenericErrorMessageWithHelpButton(message, loginFields: loginFields, sourceTag: sourceTag) + } + + return alertForGenericErrorMessage(message, loginFields: loginFields, sourceTag: sourceTag) + } + + if error.code == 403 { + message = NSLocalizedString("Incorrect username or password. Please try entering your login details again.", comment: "An error message shown when a user signed in with incorrect credentials.") + } + + if message.trim().count == 0 { + message = NSLocalizedString("Log in failed. Please try again.", comment: "A generic error message for a failed log in.") + } + + if error.code == NSURLErrorBadURL { + return alertForBadURL(with: message) + } + + return alertForGenericErrorMessage(message, loginFields: loginFields, sourceTag: sourceTag) + } + + /// Shows a generic error message. + /// + /// - Parameter message: The error message to show. + /// + private static func alertForGenericErrorMessage(_ message: String, loginFields: LoginFields, sourceTag: WordPressSupportSourceTag) -> FancyAlertViewController { + let moreHelpButton = ButtonConfig(Strings.moreHelp) { controller, _ in + controller.dismiss(animated: true) { + guard let sourceViewController = UIApplication.shared.delegate?.window??.topmostPresentedViewController, + let authDelegate = WordPressAuthenticator.shared.delegate + else { + return + } + + let state = AuthenticatorAnalyticsTracker.shared.state + authDelegate.presentSupport(from: sourceViewController, sourceTag: sourceTag, lastStep: state.lastStep, lastFlow: state.lastFlow) + } + } + + let config = FancyAlertViewController.Config(titleText: "", + bodyText: message, + headerImage: nil, + dividerPosition: .top, + defaultButton: defaultButton(), + cancelButton: nil, + moreInfoButton: moreHelpButton, + titleAccessoryButton: nil, + dismissAction: nil) + return FancyAlertViewController.controllerWithConfiguration(configuration: config) + } + + /// Shows a generic error message. + /// If Support is enabled, the view is configured so the user can open Support for assistance. + /// + /// - Parameter message: The error message to show. + /// - Parameter sourceTag: tag of the source of the error + /// + static func alertForGenericErrorMessageWithHelpButton(_ message: String, loginFields: LoginFields, sourceTag: WordPressSupportSourceTag, onDismiss: (() -> ())? = nil) -> FancyAlertViewController { + + // If support is not enabled, don't add a Help Button since it won't do anything. + var moreHelpButton: ButtonConfig? + + if WordPressAuthenticator.shared.delegate?.supportEnabled == false { + WPAuthenticatorLogInfo("Error Alert: Support not enabled. Hiding Help button.") + } else { + moreHelpButton = ButtonConfig(Strings.moreHelp) { controller, _ in + controller.dismiss(animated: true) { + // Find the topmost view controller that we can present from + guard let appDelegate = UIApplication.shared.delegate, + let window = appDelegate.window, + let viewController = window?.topmostPresentedViewController, + WordPressAuthenticator.shared.delegate?.supportEnabled == true + else { + return + } + + WordPressAuthenticator.shared.delegate?.presentSupportRequest(from: viewController, sourceTag: sourceTag) + } + } + } + + let config = FancyAlertViewController.Config(titleText: "", + bodyText: message, + headerImage: nil, + dividerPosition: .top, + defaultButton: defaultButton(onTap: onDismiss), + cancelButton: nil, + moreInfoButton: moreHelpButton, + titleAccessoryButton: nil, + dismissAction: nil) + + return FancyAlertViewController.controllerWithConfiguration(configuration: config) + } + + /// Shows a WPWalkthroughOverlayView for a bad url error message. + /// + /// - Parameter message: The error message to show. + /// + private static func alertForBadURL(with message: String) -> FancyAlertViewController { + let moreHelpButton = ButtonConfig(Strings.moreHelp) { controller, _ in + controller.dismiss(animated: true) { + // Find the topmost view controller that we can present from + guard let viewController = UIApplication.shared.delegate?.window??.topmostPresentedViewController, + let url = URL(string: "https://apps.wordpress.org/support/#faq-ios-3") + else { + return + } + + let safariViewController = SFSafariViewController(url: url) + safariViewController.modalPresentationStyle = .pageSheet + viewController.present(safariViewController, animated: true, completion: nil) + } + } + + let config = FancyAlertViewController.Config(titleText: "", + bodyText: message, + headerImage: nil, + dividerPosition: .top, + defaultButton: defaultButton(), + cancelButton: nil, + moreInfoButton: moreHelpButton, + titleAccessoryButton: nil, + dismissAction: nil) + return FancyAlertViewController.controllerWithConfiguration(configuration: config) + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/NSObject+Helpers.swift b/WordPressAuthenticator/Sources/Extensions/NSObject+Helpers.swift new file mode 100644 index 000000000000..dddb827a6b50 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/NSObject+Helpers.swift @@ -0,0 +1,12 @@ +import Foundation + +// MARK: - NSObject Helper Methods +// +extension NSObject { + + /// Returns the receiver's classname as a string, not including the namespace. + /// + class var classNameWithoutNamespaces: String { + return String(describing: self) + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/String+Underline.swift b/WordPressAuthenticator/Sources/Extensions/String+Underline.swift new file mode 100644 index 000000000000..71a36e7faf58 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/String+Underline.swift @@ -0,0 +1,24 @@ +extension String { + /// Creates an attributed string from one underlined section that's surrounded by underscores + /// + /// - Parameters: + /// - color: foreground color to use for the string (optional) + /// - underlineColor: foreground color to use for the underlined section (optional) + /// - Returns: Attributed string + /// - Note: "this _is_ underlined" would under the "is" + func underlined(color: UIColor? = nil, underlineColor: UIColor? = nil) -> NSAttributedString { + let labelParts = self.components(separatedBy: "_") + let firstPart = labelParts[0] + let underlinePart = labelParts.indices.contains(1) ? labelParts[1] : "" + let lastPart = labelParts.indices.contains(2) ? labelParts[2] : "" + + let foregroundColor = color ?? UIColor.black + let underlineForegroundColor = underlineColor ?? foregroundColor + + let underlinedString = NSMutableAttributedString(string: firstPart, attributes: [.foregroundColor: foregroundColor]) + underlinedString.append(NSAttributedString(string: underlinePart, attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue, .foregroundColor: underlineForegroundColor])) + underlinedString.append(NSAttributedString(string: lastPart, attributes: [.foregroundColor: foregroundColor])) + + return underlinedString + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIButton+Styles.swift b/WordPressAuthenticator/Sources/Extensions/UIButton+Styles.swift new file mode 100644 index 000000000000..d557d8a98f29 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIButton+Styles.swift @@ -0,0 +1,15 @@ +import UIKit + +extension UIButton { + /// Applies the style that looks like a plain text link. + func applyLinkButtonStyle() { + backgroundColor = .clear + titleLabel?.font = WPStyleGuide.fontForTextStyle(.body) + titleLabel?.textAlignment = .natural + + let buttonTitleColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonColor ?? WordPressAuthenticator.shared.style.textButtonColor + let buttonHighlightColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonHighlightColor ?? WordPressAuthenticator.shared.style.textButtonHighlightColor + setTitleColor(buttonTitleColor, for: .normal) + setTitleColor(buttonHighlightColor, for: .highlighted) + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIImage+Assets.swift b/WordPressAuthenticator/Sources/Extensions/UIImage+Assets.swift new file mode 100644 index 000000000000..bc46671f4b9a --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIImage+Assets.swift @@ -0,0 +1,51 @@ +import Foundation + +// MARK: - Named Assets +// +extension UIImage { + /// Returns the Link Image. + /// + static var linkFieldImage: UIImage { + return UIImage(named: "icon-url-field", in: bundle, compatibleWith: nil) ?? UIImage() + } + + /// Returns the Default Magic Link Image. + /// + static var magicLinkImage: UIImage { + return UIImage(named: "login-magic-link", in: bundle, compatibleWith: nil) ?? UIImage() + } + + /// Returns the Default Site Icon Placeholder Image. + /// + @objc + public static var siteAddressModalPlaceholder: UIImage { + return UIImage(named: "site-address", in: bundle, compatibleWith: nil) ?? UIImage() + } + + /// Returns the Link Image. + /// + @objc + public static var googleIcon: UIImage { + return UIImage(named: "google", in: bundle, compatibleWith: nil) ?? UIImage() + } + + /// Returns the Phone Icon. + /// + @objc + public static var phoneIcon: UIImage { + return UIImage(named: "phone-icon", in: bundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + } + + /// Returns the Key Icon. + /// + @objc + public static var keyIcon: UIImage { + return UIImage(named: "key-icon", in: bundle, compatibleWith: nil)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + } + + /// Returns WordPressAuthenticator's Bundle + /// + private static var bundle: Bundle { + return WordPressAuthenticator.bundle + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIPasteboard+Detect.swift b/WordPressAuthenticator/Sources/Extensions/UIPasteboard+Detect.swift new file mode 100644 index 000000000000..b8cb4b5ece3d --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIPasteboard+Detect.swift @@ -0,0 +1,77 @@ +extension UIPasteboard { + + /// Detects patterns and values from the UIPasteboard. This will not trigger the pasteboard alert in iOS 14. + /// - Parameters: + /// - patterns: The patterns to detect. + /// - completion: Called with the patterns and values if any were detected, otherwise contains the errors from UIPasteboard. + @available(iOS 14.0, *) + func detect(patterns: Set, completion: @escaping (Result<[UIPasteboard.DetectionPattern: Any], Error>) -> Void) { + UIPasteboard.general.detectPatterns(for: patterns) { result in + switch result { + case .success(let detections): + guard detections.isEmpty == false else { + DispatchQueue.main.async { + completion(.success([UIPasteboard.DetectionPattern: Any]())) + } + return + } + UIPasteboard.general.detectValues(for: patterns) { valuesResult in + DispatchQueue.main.async { + completion(valuesResult) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Attempts to detect and return a authenticator code from the pasteboard. + /// Expects to run on main thread. + /// - Parameters: + /// - completion: Called with a length digit authentication code on success + @available(iOS 14.0, *) + public func detectAuthenticatorCode(length: Int = 6, completion: @escaping (Result) -> Void) { + UIPasteboard.general.detect(patterns: [.number]) { result in + switch result { + case .success(let detections): + guard let firstMatch = detections.first else { + completion(.success("")) + return + } + guard let matchedNumber = firstMatch.value as? NSNumber else { + completion(.success("")) + return + } + + let authenticationCode = matchedNumber.stringValue + + /// Reject numbers with decimal points or signs in them + let codeCharacterSet = CharacterSet(charactersIn: authenticationCode) + if !codeCharacterSet.isSubset(of: CharacterSet.decimalDigits) { + completion(.success("")) + return + } + + /// We need length digits. No more, no less. + if authenticationCode.count > length { + completion(.success("")) + return + } else if authenticationCode.count == length { + completion(.success(authenticationCode)) + return + } + + let missingDigits = 6 - authenticationCode.count + let paddingZeros = String(repeating: "0", count: missingDigits) + let paddedAuthenticationCode = paddingZeros + authenticationCode + + completion(.success(paddedAuthenticationCode)) + return + case .failure(let error): + completion(.failure(error)) + return + } + } + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIStoryboard+Helpers.swift b/WordPressAuthenticator/Sources/Extensions/UIStoryboard+Helpers.swift new file mode 100644 index 000000000000..c5ca5201776c --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIStoryboard+Helpers.swift @@ -0,0 +1,30 @@ +import Foundation + +// MARK: - Storyboard enum +enum Storyboard: String { + case login = "Login" + case signup = "Signup" + case getStarted = "GetStarted" + case unifiedSignup = "UnifiedSignup" + case unifiedLoginMagicLink = "LoginMagicLink" + case emailMagicLink = "EmailMagicLink" + case siteAddress = "SiteAddress" + case googleAuth = "GoogleAuth" + case googleSignupConfirmation = "GoogleSignupConfirmation" + case twoFA = "TwoFA" + case password = "Password" + case verifyEmail = "VerifyEmail" + case nuxButtonView = "NUXButtonView" + + var instance: UIStoryboard { + return UIStoryboard(name: self.rawValue, bundle: WordPressAuthenticator.bundle) + } + + /// Returns a view controller from a Storyboard + /// assuming the identifier is the same as the class name. + /// + func instantiateViewController(ofClass classType: T.Type, creator: ((NSCoder) -> UIViewController?)? = nil) -> T? { + let identifier = classType.classNameWithoutNamespaces + return instance.instantiateViewController(identifier: identifier, creator: creator) as? T + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UITableView+Helpers.swift b/WordPressAuthenticator/Sources/Extensions/UITableView+Helpers.swift new file mode 100644 index 000000000000..972468aa3140 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UITableView+Helpers.swift @@ -0,0 +1,20 @@ +import UIKit + +extension UITableView { + /// Called in view controller's `viewDidLayoutSubviews`. If table view has a footer view, calculates the new height. + /// If new height is different from current height, updates the footer view with the new height and reassigns the table footer view. + /// Note: make sure the top-level footer view (`tableView.tableFooterView`) is frame based as a container of the Auto Layout based subview. + func updateFooterHeight() { + if let footerView = tableFooterView { + let targetSize = CGSize(width: footerView.frame.width, height: 0) + let newSize = footerView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .defaultLow) + let newHeight = newSize.height + var currentFrame = footerView.frame + if newHeight != currentFrame.size.height { + currentFrame.size.height = newHeight + footerView.frame = currentFrame + tableFooterView = footerView + } + } + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIView+AuthHelpers.swift b/WordPressAuthenticator/Sources/Extensions/UIView+AuthHelpers.swift new file mode 100644 index 000000000000..36e29efa3cbe --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIView+AuthHelpers.swift @@ -0,0 +1,18 @@ +import UIKit + +/// UIView class methods +/// +extension UIView { + /// Returns the Nib associated with the received: It's filename is expected to match the Class Name + /// + class func loadNib() -> UINib { + return UINib(nibName: classNameWithoutNamespaces, bundle: WordPressAuthenticator.bundle) + } + + /// Returns the first Object contained within the nib with a name whose name matches with the receiver's type. + /// Note: On error this method is expected to break, by design! + /// + class func instantiateFromNib() -> T { + return loadNib().instantiate(withOwner: nil, options: nil).first as! T + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIViewController+Dismissal.swift b/WordPressAuthenticator/Sources/Extensions/UIViewController+Dismissal.swift new file mode 100644 index 000000000000..0e3505f8f7ba --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIViewController+Dismissal.swift @@ -0,0 +1,22 @@ +import Foundation + +extension UIViewController { + + /// Depending on how a VC is presented we need to check different things to know whether it's being dismissed or not. + /// A VC presented as the first VC in a navigation controller needs to check if the navigation controller is being dismissed. + /// A VC added to an existing navigation controller is dismissed when `isMovingFromParent` is `true`. + /// For any other scenario `isBeingDismissed` will do. + /// + var isBeingDismissedInAnyWay: Bool { + isMovingFromParent || isBeingDismissed || (navigationController?.isBeingDismissed ?? false) + } + + /// Depending on how a VC is presented we need to check different things to know whether it's being presented or not. + /// A VC presented as the first VC in a navigation controller needs to check if the navigation controller is being presented. + /// A VC added to an existing navigation controller is presented when `isMovingToParent` is `true`. + /// For any other scenario `isBeingPresented` will do. + /// + var isBeingPresentedInAnyWay: Bool { + isMovingToParent || isBeingPresented || (navigationController?.isBeingPresented ?? false) + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/UIViewController+Helpers.swift b/WordPressAuthenticator/Sources/Extensions/UIViewController+Helpers.swift new file mode 100644 index 000000000000..0a9539a3d136 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/UIViewController+Helpers.swift @@ -0,0 +1,11 @@ +import Foundation + +// MARK: - UIViewController Helpers +extension UIViewController { + + /// Convenience method to instantiate a view controller from a storyboard. + /// + static func instantiate(from storyboard: Storyboard, creator: ((NSCoder) -> UIViewController?)? = nil) -> Self? { + return storyboard.instantiateViewController(ofClass: self, creator: creator) + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/URL+JetpackConnect.swift b/WordPressAuthenticator/Sources/Extensions/URL+JetpackConnect.swift new file mode 100644 index 000000000000..230d3616d603 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/URL+JetpackConnect.swift @@ -0,0 +1,7 @@ +import Foundation + +extension URL { + public var isJetpackConnect: Bool { + query?.contains("&source=jetpack") ?? false + } +} diff --git a/WordPressAuthenticator/Sources/Extensions/WPStyleGuide+Login.swift b/WordPressAuthenticator/Sources/Extensions/WPStyleGuide+Login.swift new file mode 100644 index 000000000000..260e5370e6b4 --- /dev/null +++ b/WordPressAuthenticator/Sources/Extensions/WPStyleGuide+Login.swift @@ -0,0 +1,350 @@ +import WordPressShared +import WordPressUI +import Gridicons +import AuthenticationServices + +final class SubheadlineButton: UIButton { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + titleLabel?.font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + setTitleColor(WordPressAuthenticator.shared.style.textButtonColor, for: .normal) + setTitleColor(WordPressAuthenticator.shared.style.textButtonHighlightColor, for: .highlighted) + } + } +} + +extension WPStyleGuide { + + private struct Constants { + static let textButtonMinHeight: CGFloat = 40.0 + static let googleIconOffset: CGFloat = -1.0 + static let googleIconButtonSize: CGFloat = 15.0 + static let domainsIconPaddingToRemove: CGFloat = 2.0 + static let domainsIconSize = CGSize(width: 18, height: 18) + static let verticalLabelSpacing: CGFloat = 10.0 + } + + /// Calculate the border based on the display + /// + class var hairlineBorderWidth: CGFloat { + return 1.0 / UIScreen.main.scale + } + + /// Common view style for signin view controllers. + /// + /// - Parameters: + /// - view: The view to style. + /// + class func configureColorsForSigninView(_ view: UIView) { + view.backgroundColor = wordPressBlue() + } + + /// Configures a plain text button with default styles. + /// + class func configureTextButton(_ button: UIButton) { + button.setTitleColor(WordPressAuthenticator.shared.style.textButtonColor, for: .normal) + button.setTitleColor(WordPressAuthenticator.shared.style.textButtonHighlightColor, for: .highlighted) + } + + /// + /// + class func colorForErrorView(_ opaque: Bool) -> UIColor { + let alpha: CGFloat = opaque ? 1.0 : 0.95 + return UIColor(fromRGBAColorWithRed: 17.0, green: 17.0, blue: 17.0, alpha: alpha) + } + + /// + /// + class func edgeInsetForLoginTextFields() -> UIEdgeInsets { + return UIEdgeInsets(top: 7, left: 20, bottom: 7, right: 20) + } + + class func textInsetsForLoginTextFieldWithLeftView() -> UIEdgeInsets { + return UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 0) + } + + /// Return the system font in medium weight for the given style + /// + /// - note: iOS won't return UIFontWeightMedium for dynamic system font :( + /// So instead get the dynamic font size, then ask for the non-dynamic font at that size + /// + class func mediumWeightFont(forStyle style: UIFont.TextStyle, maximumPointSize: CGFloat = WPStyleGuide.maxFontSize) -> UIFont { + let fontToGetSize = WPStyleGuide.fontForTextStyle(style) + let maxAllowedFontSize = CGFloat.minimum(fontToGetSize.pointSize, maximumPointSize) + return UIFont.systemFont(ofSize: maxAllowedFontSize, weight: .medium) + } + + // MARK: - Login Button Methods + + /// Creates a button for Google Sign-in with hyperlink style. + /// + /// - Returns: A properly styled UIButton + /// + class func googleLoginButton() -> UIButton { + let baseString = NSLocalizedString("{G} Log in with Google.", comment: "Label for button to log in using Google. The {G} will be replaced with the Google logo.") + + let attrStrNormal = googleButtonString(baseString, linkColor: WordPressAuthenticator.shared.style.textButtonColor) + let attrStrHighlight = googleButtonString(baseString, linkColor: WordPressAuthenticator.shared.style.textButtonHighlightColor) + + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + + return textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font) + } + + /// Creates an attributed string that includes the Google logo. + /// + /// - Parameters: + /// - forHyperlink: Indicates if the string will be displayed in a hyperlink. + /// Otherwise, it will be styled to be displayed on a NUXButton. + /// - Returns: A properly styled NSAttributedString + /// + class func formattedGoogleString(forHyperlink: Bool = false) -> NSAttributedString { + + let googleAttachment = NSTextAttachment() + let googleIcon = UIImage.googleIcon + googleAttachment.image = googleIcon + + if forHyperlink { + // Create an attributed string that contains the Google icon. + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + googleAttachment.bounds = CGRect(x: 0, + y: font.descender + Constants.googleIconOffset, + width: googleIcon.size.width, + height: googleIcon.size.height) + + return NSAttributedString(attachment: googleAttachment) + } else { + let nuxButtonTitleFont = WPStyleGuide.mediumWeightFont(forStyle: .title3) + let googleTitle = NSLocalizedString("Continue with Google", + comment: "Button title. Tapping begins log in using Google.") + return attributedStringwithLogo(googleIcon, + imageSize: .init(width: Constants.googleIconButtonSize, height: Constants.googleIconButtonSize), + title: googleTitle, + titleFont: nuxButtonTitleFont) + } + } + + /// Creates an attributed string that includes the Apple logo. + /// + /// - Returns: A properly styled NSAttributedString to be displayed on a NUXButton. + /// + class func formattedAppleString() -> NSAttributedString { + let attributedString = NSMutableAttributedString() + + let appleSymbol = "" + let appleSymbolAttributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.systemFont(ofSize: 23) + ] + attributedString.append(NSAttributedString(string: appleSymbol, attributes: appleSymbolAttributes)) + + // Add leading non-breaking space to separate the button text from the Apple symbol. + let space = "\u{00a0}\u{00a0}" + attributedString.append(NSAttributedString(string: space)) + + let title = NSLocalizedString("Continue with Apple", comment: "Button title. Tapping begins log in using Apple.") + attributedString.append(NSAttributedString(string: title)) + + return NSAttributedString(attributedString: attributedString) + } + + /// Creates an attributed string that includes the `linkFieldImage` + /// + /// - Returns: A properly styled NSAttributedString to be displayed on a NUXButton. + /// + class func formattedSignInWithSiteCredentialsString() -> NSAttributedString { + let title = WordPressAuthenticator.shared.displayStrings.signInWithSiteCredentialsButtonTitle + let globe = UIImage.gridicon(.globe) + let image = globe.imageWithTintColor(WordPressAuthenticator.shared.style.placeholderColor) ?? globe + return attributedStringwithLogo(image, + imageSize: image.size, + title: title, + titleFont: WPStyleGuide.mediumWeightFont(forStyle: .title3)) + } + + /// Creates a button for Self-hosted Login + /// + /// - Returns: A properly styled UIButton + /// + class func selfHostedLoginButton(alignment: UIControl.NaturalContentHorizontalAlignment = .leading) -> UIButton { + + let style = WordPressAuthenticator.shared.style + + let button: UIButton + + if WordPressAuthenticator.shared.configuration.showLoginOptions { + let baseString = NSLocalizedString("Or log in by _entering your site address_.", comment: "Label for button to log in using site address. Underscores _..._ denote underline.") + + let attrStrNormal = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonColor) + let attrStrHighlight = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonHighlightColor) + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + + button = textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font, alignment: alignment) + } else { + let baseString = NSLocalizedString("Enter the address of the WordPress site you'd like to connect.", comment: "Label for button to log in using your site address.") + + let attrStrNormal = selfHostedButtonString(baseString, linkColor: style.textButtonColor) + let attrStrHighlight = selfHostedButtonString(baseString, linkColor: style.textButtonHighlightColor) + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + + button = textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font) + } + + button.accessibilityIdentifier = "Self Hosted Login Button" + + return button + } + + /// Creates a button for wpcom signup on the email screen + /// + /// - Returns: A UIButton styled for wpcom signup + /// - Note: This button is only used during Jetpack setup, not the usual flows + /// + class func wpcomSignupButton() -> UIButton { + let style = WordPressAuthenticator.shared.style + let baseString = NSLocalizedString("Don't have an account? _Sign up_", comment: "Label for button to log in using your site address. The underscores _..._ denote underline") + let attrStrNormal = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonColor) + let attrStrHighlight = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonHighlightColor) + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + + return textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font) + } + + /// Creates a button to open our T&C + /// + /// - Returns: A properly styled UIButton + /// + class func termsButton() -> UIButton { + let style = WordPressAuthenticator.shared.style + + let baseString = NSLocalizedString("By signing up, you agree to our _Terms of Service_.", comment: "Legal disclaimer for signup buttons, the underscores _..._ denote underline") + + let attrStrNormal = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonColor) + let attrStrHighlight = baseString.underlined(color: style.subheadlineColor, underlineColor: style.textButtonHighlightColor) + let font = WPStyleGuide.mediumWeightFont(forStyle: .footnote) + + return textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font, alignment: .center) + } + + /// Creates a button to open our T&C. + /// Specifically, the Sign Up verbiage on the Get Started view. + /// - Returns: A properly styled UIButton + /// + class func signupTermsButton() -> UIButton { + let unifiedStyle = WordPressAuthenticator.shared.unifiedStyle + let originalStyle = WordPressAuthenticator.shared.style + let baseString = WordPressAuthenticator.shared.displayStrings.signupTermsOfService + let textColor = unifiedStyle?.textSubtleColor ?? originalStyle.subheadlineColor + let linkColor = unifiedStyle?.textButtonColor ?? originalStyle.textButtonColor + + let attrStrNormal = baseString.underlined(color: textColor, underlineColor: linkColor) + let attrStrHighlight = baseString.underlined(color: textColor, underlineColor: linkColor) + let font = WPStyleGuide.mediumWeightFont(forStyle: .footnote) + + let button = textButton(normal: attrStrNormal, highlighted: attrStrHighlight, font: font, alignment: .center, forUnified: true) + button.titleLabel?.textAlignment = .center + return button + } + + private class func textButton(normal normalString: NSAttributedString, highlighted highlightString: NSAttributedString, font: UIFont, alignment: UIControl.NaturalContentHorizontalAlignment = .leading, forUnified: Bool = false) -> UIButton { + let button = SubheadlineButton() + button.clipsToBounds = true + + button.naturalContentHorizontalAlignment = alignment + button.translatesAutoresizingMaskIntoConstraints = false + button.titleLabel?.font = font + button.titleLabel?.numberOfLines = 0 + button.titleLabel?.lineBreakMode = .byWordWrapping + button.setTitleColor(WordPressAuthenticator.shared.style.subheadlineColor, for: .normal) + + // These constraints work around some issues with multiline buttons and + // vertical layout. Without them the button's height may not account + // for the titleLabel's height. + + let verticalLabelSpacing = forUnified ? 0 : Constants.verticalLabelSpacing + button.titleLabel?.topAnchor.constraint(equalTo: button.topAnchor, constant: verticalLabelSpacing).isActive = true + button.titleLabel?.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -verticalLabelSpacing).isActive = true + button.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.textButtonMinHeight).isActive = true + + button.setAttributedTitle(normalString, for: .normal) + button.setAttributedTitle(highlightString, for: .highlighted) + return button + } + + private class func googleButtonString(_ baseString: String, linkColor: UIColor) -> NSAttributedString { + let labelParts = baseString.components(separatedBy: "{G}") + + let firstPart = labelParts[0] + // 👇 don't want to crash when a translation lacks "{G}" + let lastPart = labelParts.indices.contains(1) ? labelParts[1] : "" + + let labelString = NSMutableAttributedString(string: firstPart, attributes: [.foregroundColor: WPStyleGuide.greyDarken30()]) + + if lastPart != "" { + labelString.append(formattedGoogleString(forHyperlink: true)) + } + + labelString.append(NSAttributedString(string: lastPart, attributes: [.foregroundColor: linkColor])) + + return labelString + } + + private class func selfHostedButtonString(_ buttonText: String, linkColor: UIColor) -> NSAttributedString { + let font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + + let titleParagraphStyle = NSMutableParagraphStyle() + titleParagraphStyle.alignment = .left + + let labelString = NSMutableAttributedString(string: "") + + if let originalDomainsIcon = UIImage.gridicon(.domains).imageWithTintColor(WordPressAuthenticator.shared.style.placeholderColor) { + var domainsIcon = originalDomainsIcon.cropping(to: CGRect(x: Constants.domainsIconPaddingToRemove, + y: Constants.domainsIconPaddingToRemove, + width: originalDomainsIcon.size.width - Constants.domainsIconPaddingToRemove * 2, + height: originalDomainsIcon.size.height - Constants.domainsIconPaddingToRemove * 2)) + domainsIcon = domainsIcon.resizedImage(Constants.domainsIconSize, interpolationQuality: .high) + let domainsAttachment = NSTextAttachment() + domainsAttachment.image = domainsIcon + domainsAttachment.bounds = CGRect(x: 0, y: font.descender, width: domainsIcon.size.width, height: domainsIcon.size.height) + let iconString = NSAttributedString(attachment: domainsAttachment) + labelString.append(iconString) + } + labelString.append(NSAttributedString(string: " " + buttonText, attributes: [.foregroundColor: linkColor])) + + return labelString + } +} + +// MARK: Attributed String Helpers +// +private extension WPStyleGuide { + + /// Creates an attributed string with a logo and title. + /// The logo is prepended to the title. + /// + /// - Parameters: + /// - logoImage: UIImage representing the logo + /// - imageSize: Size of the UIImage + /// - title: title String to be appended to the logoImage + /// - titleFont: UIFont for the title String + /// + /// - Returns: A properly styled NSAttributedString to be displayed on a NUXButton. + /// + class func attributedStringwithLogo(_ logoImage: UIImage, + imageSize: CGSize, + title: String, + titleFont: UIFont) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = logoImage + + attachment.bounds = CGRect(x: 0, y: (titleFont.capHeight - imageSize.height) / 2, + width: imageSize.width, height: imageSize.height) + + let buttonString = NSMutableAttributedString(attachment: attachment) + // Add leading non-breaking spaces to separate the button text from the logo. + let title = "\u{00a0}\u{00a0}" + title + buttonString.append(NSAttributedString(string: title)) + + return buttonString + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/ASWebAuthenticationSession+Utils.swift .swift b/WordPressAuthenticator/Sources/GoogleSignIn/ASWebAuthenticationSession+Utils.swift .swift new file mode 100644 index 000000000000..2d4891a5848c --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/ASWebAuthenticationSession+Utils.swift .swift @@ -0,0 +1,20 @@ +import AuthenticationServices + +extension ASWebAuthenticationSession { + + /// Wrapper around the default `init(url:, callbackULRScheme:, completionHandler:)` where the + /// `completionHandler` argument is a `Result` instead of a `URL` and `Error` pair. + convenience init(url: URL, callbackURLScheme: String, completionHandler: @escaping (Result) -> Void) { + self.init(url: url, callbackURLScheme: callbackURLScheme) { callbackURL, error in + completionHandler( + Result( + value: callbackURL, + error: error, + // Unfortunately we cannot exted `ASWebAuthenticationSessionError.Code` to add + // a custom error for this scenario, so we're left to use a "generic" one. + inconsistentStateError: OAuthError.inconsistentWebAuthenticationSessionCompletion + ) + ) + } + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/Character+URLSafe.swift b/WordPressAuthenticator/Sources/GoogleSignIn/Character+URLSafe.swift new file mode 100644 index 000000000000..63d7bccc5264 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/Character+URLSafe.swift @@ -0,0 +1,10 @@ +extension Character { + + // From the docs: using the unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + // That is, URL safe characters. + // + // Notice that Swift offers `CharacterSet.urlQueryAllowed` to represent this set of characters. + // However, there is no straightforward way to convert a `CharacterSet` to a `Set`. + // See for example https://nshipster.com/characterset/. + static let urlSafeCharacters = Set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~") +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/Data+Base64URL.swift b/WordPressAuthenticator/Sources/GoogleSignIn/Data+Base64URL.swift new file mode 100644 index 000000000000..30ed99509535 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/Data+Base64URL.swift @@ -0,0 +1,34 @@ +public extension Data { + + /// "base64url" is an encoding that is safe to use with URLs. + /// It is defined in RFC 4648, section 5. + /// + /// See: + /// - https://tools.ietf.org/html/rfc4648#section-5 + /// - https://tools.ietf.org/html/rfc7515#appendix-C + init?(base64URLEncoded: String) { + let base64 = base64URLEncoded + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let length = Double(base64.lengthOfBytes(using: String.Encoding.utf8)) + let requiredLength = 4 * ceil(length / 4.0) + let paddingLength = requiredLength - length + if paddingLength > 0 { + let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) + self.init(base64Encoded: base64 + padding, options: .ignoreUnknownCharacters) + } else { + self.init(base64Encoded: base64, options: .ignoreUnknownCharacters) + } + } + + /// See https://tools.ietf.org/html/rfc4648#section-5 + /// + /// Function name to match the standard library's `base64EncodedString()`. + func base64URLEncodedString() -> String { + base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/Data+SHA256.swift b/WordPressAuthenticator/Sources/GoogleSignIn/Data+SHA256.swift new file mode 100644 index 000000000000..82f14056f7ba --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/Data+SHA256.swift @@ -0,0 +1,12 @@ +import CryptoKit + +extension Data { + + func sha256Hashed() -> Data { + Data(SHA256.hash(data: self)) + } + + func sha256Hashed() -> String { + SHA256.hash(data: self).map { String(format: "%02hhx", $0) }.joined() + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/DataGetting.swift b/WordPressAuthenticator/Sources/GoogleSignIn/DataGetting.swift new file mode 100644 index 000000000000..f817743a93f9 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/DataGetting.swift @@ -0,0 +1,4 @@ +protocol DataGetting { + + func data(for request: URLRequest) async throws -> Data +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/GoogleClientId.swift b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleClientId.swift new file mode 100644 index 000000000000..4adb4cc29014 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleClientId.swift @@ -0,0 +1,26 @@ +public struct GoogleClientId { + + let value: String + + public init?(string: String) { + guard string.split(separator: ".").count > 1 else { + return nil + } + self.value = string + } + + /// See https://developers.google.com/identity/protocols/oauth2/native-app#step1-code-verifier + func redirectURI(path: String?) -> String { + let root = value.split(separator: ".").reversed().joined(separator: ".") + + guard let path else { + return root + } + + return "\(root):/\(path)" + } + + var defaultRedirectURI: String { + redirectURI(path: "oauth2callback") + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetter.swift b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetter.swift new file mode 100644 index 000000000000..abfd0210c0f9 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetter.swift @@ -0,0 +1,28 @@ +class GoogleOAuthTokenGetter: GoogleOAuthTokenGetting { + + let dataGetter: DataGetting + + init(dataGetter: DataGetting = URLSession.shared) { + self.dataGetter = dataGetter + } + + func getToken( + clientId: GoogleClientId, + audience: String, + authCode: String, + pkce: ProofKeyForCodeExchange + ) async throws -> OAuthTokenResponseBody { + let request = try URLRequest.googleSignInTokenRequest( + body: .googleSignInRequestBody( + clientId: clientId, + audience: audience, + authCode: authCode, + pkce: pkce + ) + ) + + let data = try await dataGetter.data(for: request) + + return try JSONDecoder().decode(OAuthTokenResponseBody.self, from: data) + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetting.swift b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetting.swift new file mode 100644 index 000000000000..c505494a3533 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/GoogleOAuthTokenGetting.swift @@ -0,0 +1,9 @@ +protocol GoogleOAuthTokenGetting { + + func getToken( + clientId: GoogleClientId, + audience: String, + authCode: String, + pkce: ProofKeyForCodeExchange + ) async throws -> OAuthTokenResponseBody +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/IDToken.swift b/WordPressAuthenticator/Sources/GoogleSignIn/IDToken.swift new file mode 100644 index 000000000000..706a9015e9f6 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/IDToken.swift @@ -0,0 +1,23 @@ +/// See https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo +public struct IDToken { + + public let token: JSONWebToken + public let name: String + public let email: String + + // TODO: Validate token! – https://developers.google.com/identity/openid-connect/openid-connect#validatinganidtoken + init?(jwt: JSONWebToken) { + // Name and email might not be part of the JWT Google sent us if the scope used for the + // request didn't include them + guard let email = jwt.payload["email"] as? String else { + return nil + } + guard let name = jwt.payload["name"] as? String else { + return nil + } + + self.token = jwt + self.name = name + self.email = email + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/JSONWebToken.swift b/WordPressAuthenticator/Sources/GoogleSignIn/JSONWebToken.swift new file mode 100644 index 000000000000..a45156756430 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/JSONWebToken.swift @@ -0,0 +1,47 @@ +/// Represents a JSON Web Token (JWT) +/// +/// See https://jwt.io/introduction +public struct JSONWebToken { + let rawValue: String + + let header: [String: Any] + let payload: [String: Any] + let signature: String + + init?(encodedString: String) { + let segments = encodedString.components(separatedBy: ".") + + // JWT has three segments: header, payload, and signature + guard segments.count == 3 else { + return nil + } + + // Notice that JWT uses base64url encoding, not base64. + // + // See: + // - https://tools.ietf.org/html/rfc7515#appendix-C + // - https://jwt.io/introduction + + // Note: Splitting the guards is useful to know which one fails + guard let headerData = Data(base64URLEncoded: segments[0]) else { + return nil + } + + guard let payloadData = Data(base64URLEncoded: segments[1]) else { + return nil + } + + guard let header = try? JSONSerialization.jsonObject(with: headerData, options: []) as? [String: Any] else { + return nil + } + + guard let payload = try? JSONSerialization.jsonObject(with: payloadData, options: []) as? [String: Any] else { + return nil + } + + self.rawValue = encodedString + self.header = header + self.payload = payload + self.signature = segments[2] + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/NewGoogleAuthenticator.swift b/WordPressAuthenticator/Sources/GoogleSignIn/NewGoogleAuthenticator.swift new file mode 100644 index 000000000000..4548966ed31a --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/NewGoogleAuthenticator.swift @@ -0,0 +1,123 @@ +import AuthenticationServices + +public class NewGoogleAuthenticator: NSObject { + + let clientId: GoogleClientId + let scheme: String + let audience: String + let oauthTokenGetter: GoogleOAuthTokenGetting + + public convenience init( + clientId: GoogleClientId, + scheme: String, + audience: String, + urlSession: URLSession + ) { + self.init( + clientId: clientId, + scheme: scheme, + audience: audience, + oautTokenGetter: GoogleOAuthTokenGetter(dataGetter: urlSession) + ) + } + + init( + clientId: GoogleClientId, + scheme: String, + audience: String, + oautTokenGetter: GoogleOAuthTokenGetting + ) { + self.clientId = clientId + self.scheme = scheme + self.audience = audience + self.oauthTokenGetter = oautTokenGetter + } + + /// Get the user's OAuth token from their Google account. This token can be used to authenticate with the WordPress backend. + /// + /// The app will present the browser to hand over authentication to Google from the given `UIViewController`. + public func getOAuthToken(from viewController: UIViewController) async throws -> IDToken { + return try await getOAuthToken( + from: WebAuthenticationPresentationContext(viewController: viewController) + ) + } + + /// Get the user's OAuth token from their Google account. This token can be used to authenticate with the WordPress backend. + /// + /// The app will present the browser to hand over authentication to Google using the given + /// `ASWebAuthenticationPresentationContextProviding`. + public func getOAuthToken( + from contextProvider: ASWebAuthenticationPresentationContextProviding + ) async throws -> IDToken { + let pkce = try ProofKeyForCodeExchange() + let url = try await getURL( + clientId: clientId, + scheme: scheme, + pkce: pkce, + contextProvider: contextProvider + ) + return try await requestOAuthToken(url: url, clientId: clientId, audience: audience, pkce: pkce) + } + + func getURL( + clientId: GoogleClientId, + scheme: String, + pkce: ProofKeyForCodeExchange, + contextProvider: ASWebAuthenticationPresentationContextProviding + ) async throws -> URL { + let url = try URL.googleSignInAuthURL(clientId: clientId, pkce: pkce) + return try await withCheckedThrowingContinuation { continuation in + let session = ASWebAuthenticationSession( + url: url, + callbackURLScheme: scheme, + completionHandler: { result in + continuation.resume(with: result) + } + ) + + session.presentationContextProvider = contextProvider + // At this point in time, we don't see the need to make the session ephemeral. + // + // Additionally, from a user's perspective, it would be frustrating to have to + // authenticate with Google again unless necessary—it certainly would be when testing + // the app. + session.prefersEphemeralWebBrowserSession = false + + // It feels inappropriate to force a dispatch on the main queue deep within the library. + // However, this is required to ensure `session` accesses the view it needs for the presentation on the right thread. + // + // See tradeoffs consideration at: + // https://github.com/wordpress-mobile/WordPressAuthenticator-iOS/pull/743#discussion_r1109325159 + DispatchQueue.main.async { + session.start() + } + } + } + + func requestOAuthToken( + url: URL, + clientId: GoogleClientId, + audience: String, + pkce: ProofKeyForCodeExchange + ) async throws -> IDToken { + guard let authCode = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "code" })? + .value else { + throw OAuthError.urlDidNotContainCodeParameter(url: url) + } + + let response = try await oauthTokenGetter.getToken( + clientId: clientId, + audience: audience, + authCode: authCode, + pkce: pkce + ) + + guard let idToken = response.idToken else { + throw OAuthError.tokenResponseDidNotIncludeIdToken + } + + return idToken + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/OAuthError.swift b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthError.swift new file mode 100644 index 000000000000..329d3c3dd19d --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthError.swift @@ -0,0 +1,24 @@ +public enum OAuthError: LocalizedError { + + // ASWebAuthenticationSession + case inconsistentWebAuthenticationSessionCompletion + + case failedToGenerateSecureRandomCodeVerifier(status: Int32) + + // OAuth token response + case urlDidNotContainCodeParameter(url: URL) + case tokenResponseDidNotIncludeIdToken + + public var errorDescription: String { + switch self { + case .inconsistentWebAuthenticationSessionCompletion: + return "ASWebAuthenticationSession authentication finished with neither a callback URL nor error" + case .failedToGenerateSecureRandomCodeVerifier(let status): + return "Could not generate a cryptographically secure random PKCE code verifier value. Underlying error code \(status)" + case .urlDidNotContainCodeParameter(let url): + return "Could not find 'code' parameter in URL '\(url)'" + case .tokenResponseDidNotIncludeIdToken: + return "OAuth token response did not include idToken" + } + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift new file mode 100644 index 000000000000..7f3df6b157b5 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthRequestBody+GoogleSignIn.swift @@ -0,0 +1,26 @@ +extension OAuthTokenRequestBody { + + static func googleSignInRequestBody( + clientId: GoogleClientId, + audience: String, + authCode: String, + pkce: ProofKeyForCodeExchange + ) -> Self { + .init( + clientId: clientId.value, + // "The client secret obtained from the API Console Credentials page." + // - https://developers.google.com/identity/protocols/oauth2/native-app#step-2:-send-a-request-to-googles-oauth-2.0-server + // + // There doesn't seem to be any secret for iOS app credentials. + // The process works with an empty string... + clientSecret: "", + audience: audience, + code: authCode, + codeVerifier: pkce.codeVerifier, + // As defined in the OAuth 2.0 specification, this field's value must be set to authorization_code. + // – https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code + grantType: "authorization_code", + redirectURI: clientId.defaultRedirectURI + ) + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenRequestBody.swift b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenRequestBody.swift new file mode 100644 index 000000000000..49cd824b4009 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenRequestBody.swift @@ -0,0 +1,45 @@ +/// Models the request to send for an OAuth token +/// +/// - Note: See documentation at https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code +struct OAuthTokenRequestBody { + let clientId: String + let clientSecret: String + let audience: String + let code: String + let codeVerifier: ProofKeyForCodeExchange.CodeVerifier + let grantType: String + let redirectURI: String + + enum CodingKeys: String, CodingKey { + case clientId = "client_id" + case clientSecret = "client_secret" + case audience + case code + case codeVerifier = "code_verifier" + case grantType = "grant_type" + case redirectURI = "redirect_uri" + } + + func asURLEncodedData() throws -> Data { + let params = [ + (CodingKeys.clientId.rawValue, clientId), + (CodingKeys.clientSecret.rawValue, clientSecret), + (CodingKeys.code.rawValue, code), + (CodingKeys.codeVerifier.rawValue, codeVerifier.rawValue), + (CodingKeys.grantType.rawValue, grantType), + (CodingKeys.redirectURI.rawValue, redirectURI), + // This is not in the spec at + // https://developers.google.com/identity/protocols/oauth2/native-app#step-2:-send-a-request-to-googles-oauth-2.0-server + // but we'll get an idToken that our backend considers invalid if omitted. + (CodingKeys.audience.rawValue, audience), + ] + + let items = params.map { URLQueryItem(name: $0.0, value: $0.1) } + + var components = URLComponents() + components.queryItems = items + + // We can assume `query` to never be nil because we set `queryItems` in the line above. + return Data(components.query!.utf8) + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenResponseBody.swift b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenResponseBody.swift new file mode 100644 index 000000000000..bae6767608a8 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/OAuthTokenResponseBody.swift @@ -0,0 +1,35 @@ +/// Models the response to an OAuth token request. +/// +/// - Note: See documentation at https://developers.google.com/identity/protocols/oauth2/native-app#exchange-authorization-code +struct OAuthTokenResponseBody: Codable, Equatable { + let accessToken: String + let expiresIn: Int + /// This value is only returned if the request included an identity scope, such as openid, profile, or email. + /// The value is a JSON Web Token (JWT) that contains digitally signed identity information about the user. + let rawIDToken: String? + let refreshToken: String? + let scope: String + /// The type of token returned. At this time, this field's value is always set to Bearer. + let tokenType: String + + var idToken: IDToken? { + guard let rawIDToken = rawIDToken else { + return nil + } + + guard let jwt = JSONWebToken(encodedString: rawIDToken) else { + return nil + } + + return IDToken(jwt: jwt) + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresIn = "expires_in" + case rawIDToken = "id_token" + case refreshToken = "refresh_token" + case scope + case tokenType = "token_type" + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/ProofKeyForCodeExchange.swift b/WordPressAuthenticator/Sources/GoogleSignIn/ProofKeyForCodeExchange.swift new file mode 100644 index 000000000000..84d8f6ad2818 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/ProofKeyForCodeExchange.swift @@ -0,0 +1,120 @@ +// See: +// - https://developers.google.com/identity/protocols/oauth2/native-app#step1-code-verifier +// - https://www.rfc-editor.org/rfc/rfc7636 +// +// Note: The common abbreviation of "Proof Key for Code Exchange" is PKCE and is pronounced "pixy". +struct ProofKeyForCodeExchange: Equatable { + + enum Method: Equatable { + case s256 + case plain + + var urlQueryParameterValue: String { + switch self { + case .plain: return "plain" + case .s256: return "S256" + } + } + } + + let codeVerifier: CodeVerifier + let method: Method + + init() throws { + self.codeVerifier = try .makeRandomCodeVerifier() + self.method = .s256 + } + + init(codeVerifier: CodeVerifier, method: Method) { + self.codeVerifier = codeVerifier + self.method = method + } + + var codeChallenge: String { + codeVerifier.codeChallenge(using: method) + } +} + +extension ProofKeyForCodeExchange { + + // A code_verifier is a high-entropy cryptographic random string using the unreserved + // characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~", with a minimum length of 43 + // characters and a maximum length of 128 characters. + // + // The code verifier should have enough entropy to make it impractical to guess the value. + // + // See: + // - https://www.rfc-editor.org/rfc/rfc7636#section-4.1 + // - https://developers.google.com/identity/protocols/oauth2/native-app#step1-code-verifier + struct CodeVerifier: Equatable { + + let rawValue: String + + static let allowedCharacters = Character.urlSafeCharacters + static let allowedLengthRange = (43...128) + + /// Generates a random code verifier according to the PKCE RFC. + /// + /// - Note: This method name is more verbose than the recommended "make" for this factory to communicate the randomness component. + static func makeRandomCodeVerifier() throws -> Self { + let value = try randomSecureCodeVerifier() + + // It's appropriate to force unwrap here because a `nil` value could only result from + // a developer error—either wrong coding of the constrained length or of the allowed + // characters. + return .init(value: value)! + } + + init?(value: String) { + guard CodeVerifier.allowedLengthRange.contains(value.count) else { return nil } + + guard Set(value).isSubset(of: CodeVerifier.allowedCharacters) else { return nil } + + self.rawValue = value + } + + func codeChallenge(using method: Method) -> String { + switch method { + case .s256: + // The spec defines code_challenge for the s256 mode as: + // + // code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + // + // We don't need the ASCII conversion, because we build `CodeVerifier` from URL safe + // characters. + let rawData = Data(rawValue.utf8) + let hashedData: Data = rawData.sha256Hashed() + return hashedData.base64URLEncodedString() + case .plain: + return rawValue + } + } + } + + /// Generates a random code verifier according to the PKCE RFC. + /// + /// The RFC states: + /// + /// > It is RECOMMENDED that the output of a suitable random number generator be used to create a 32-octet sequence. + /// > The octet sequence is then base64url-encoded to produce a 43-octet URL safe string to use as the code verifier. + static func randomSecureCodeVerifier() throws -> String { + let byteCount = 32 + var bytes = [UInt8](repeating: 0, count: byteCount) + let result = SecRandomCopyBytes(kSecRandomDefault, byteCount, &bytes) + + guard result == errSecSuccess else { + throw OAuthError.failedToGenerateSecureRandomCodeVerifier(status: result) + } + + let data = Data(bytes) + + // Base64url-encoding a 32-octect sequence should always result in a 43-length string, + // string, but let's cap it just in case. + // + // Also notice that by base64url-encoding, we ensure the characters are in the allowed + // set. + // + // 43 is also the minimum length for a code verifier, hence the `allowedLengthRange.lowerBound` usage. + return String(data.base64URLEncodedString().prefix(CodeVerifier.allowedLengthRange.lowerBound)) + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/Result+ConvenienceInit.swift b/WordPressAuthenticator/Sources/GoogleSignIn/Result+ConvenienceInit.swift new file mode 100644 index 000000000000..2ea91504226a --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/Result+ConvenienceInit.swift @@ -0,0 +1,15 @@ +extension Swift.Result { + + /// Convenience init to lift the values in an Objective-C style callback, where both success and failure parameters can be nil, to + /// a domain where at least one is not nil. + /// + /// If both values are nil, it will create a `failure` instance wrapping the given `inconsistentStateError`. + init(value: Success?, error: Failure?, inconsistentStateError: Failure) { + switch (value, error) { + case (.some(let value), .none): self = .success(value) + case (.some, .some(let error)): self = .failure(error) + case (.none, .some(let error)): self = .failure(error) + case (.none, .none): self = .failure(inconsistentStateError) + } + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/URL+GoogleSignIn.swift b/WordPressAuthenticator/Sources/GoogleSignIn/URL+GoogleSignIn.swift new file mode 100644 index 000000000000..8c439682c5b7 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/URL+GoogleSignIn.swift @@ -0,0 +1,39 @@ +import Foundation + +// It's acceptable to force-unwrap here because, for this call to fail we'd need a developer error, +// which we would catch because the unit tests would crash. +extension URL { + + static var googleSignInBaseURL = URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! + + static var googleSignInOAuthTokenURL = URL(string: "https://oauth2.googleapis.com/token")! +} + +extension URL { + + static func googleSignInAuthURL(clientId: GoogleClientId, pkce: ProofKeyForCodeExchange) throws -> URL { + let queryItems = [ + ("client_id", clientId.value), + ("code_challenge", pkce.codeChallenge), + ("code_challenge_method", pkce.method.urlQueryParameterValue), + ("redirect_uri", clientId.defaultRedirectURI), + ("response_type", "code"), + // See what the Google SDK does: + // https://github.com/google/GoogleSignIn-iOS/blob/7.0.0/GoogleSignIn/Sources/GIDScopes.m#L58-L61 + ("scope", "profile email") + ].map { URLQueryItem(name: $0.0, value: $0.1) } + + if #available(iOS 16.0, *) { + return googleSignInBaseURL.appending(queryItems: queryItems) + } else { + // Given `googleSignInBaseURL` is assumed as a valid URL, a `URLComponents` instance + // should always be available. + var components = URLComponents(url: googleSignInBaseURL, resolvingAgainstBaseURL: false)! + components.queryItems = queryItems + // Likewise, we can as long as the given `queryItems` are valid, we can assume `url` to + // not be nil. If `queryItems` are invalid, a developer error has been committed, and + // crashing is appropriate. + return components.url! + } + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/URLRequest+GoogleSignIn.swift b/WordPressAuthenticator/Sources/GoogleSignIn/URLRequest+GoogleSignIn.swift new file mode 100644 index 000000000000..5bd8c78275e1 --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/URLRequest+GoogleSignIn.swift @@ -0,0 +1,18 @@ +extension URLRequest { + + static func googleSignInTokenRequest( + body: OAuthTokenRequestBody + ) throws -> URLRequest { + var request = URLRequest(url: URL.googleSignInOAuthTokenURL) + request.httpMethod = "POST" + + request.setValue( + "application/x-www-form-urlencoded; charset=UTF-8", + forHTTPHeaderField: "Content-Type" + ) + + request.httpBody = try body.asURLEncodedData() + + return request + } +} diff --git a/WordPressAuthenticator/Sources/GoogleSignIn/URLSesison+DataGetting.swift b/WordPressAuthenticator/Sources/GoogleSignIn/URLSesison+DataGetting.swift new file mode 100644 index 000000000000..fef3acbc528a --- /dev/null +++ b/WordPressAuthenticator/Sources/GoogleSignIn/URLSesison+DataGetting.swift @@ -0,0 +1,20 @@ +import Foundation + +// In other projects, we avoid extending `URLSession` to conform to "-getting" protocols shaped +// like `DataGetting` and prefer composition instead, creating an object conforming to the protocol +// and holding an `URLSession` reference. +// +// The concern with extending a Foundation type with special-purpose domain object getting ability +// is that it would pollute the namespace, offering the protocol methods as an option everywhere +// `URLSession` is used, even in part of the app that are unrelated with the resource. +// +// But since the type `DataGetting` revolves around is `Data` and `URLSession` already exposes +// methods returning `Data`, this feels more like a syntax-sugar extension rather than one adding +// whole new domain-specific APIs to the type. +extension URLSession: DataGetting { + + func data(for request: URLRequest) async throws -> Data { + let (data, _) = try await data(for: request) + return data + } +} diff --git a/WordPressAuthenticator/Sources/Info.plist b/WordPressAuthenticator/Sources/Info.plist new file mode 100644 index 000000000000..b6286a4aab8e --- /dev/null +++ b/WordPressAuthenticator/Sources/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.h b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.h new file mode 100644 index 000000000000..42916678077b --- /dev/null +++ b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.h @@ -0,0 +1,22 @@ +#import + +@import WordPressShared; + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXTERN id _Nullable WPAuthenticatorGetLoggingDelegate(void); +FOUNDATION_EXTERN void WPAuthenticatorSetLoggingDelegate(id _Nullable logger); + +FOUNDATION_EXTERN void WPAuthenticatorLogError(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPAuthenticatorLogWarning(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPAuthenticatorLogInfo(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPAuthenticatorLogDebug(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPAuthenticatorLogVerbose(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); + +FOUNDATION_EXTERN void WPAuthenticatorLogvError(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPAuthenticatorLogvWarning(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPAuthenticatorLogvInfo(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPAuthenticatorLogvDebug(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPAuthenticatorLogvVerbose(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); + +NS_ASSUME_NONNULL_END diff --git a/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.m b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.m new file mode 100644 index 000000000000..fbd7c33a06dd --- /dev/null +++ b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.m @@ -0,0 +1,49 @@ +#import "WPAuthenticatorLogging.h" + +static id wordPressAuthenticatorLogger = nil; + +id _Nullable WPAuthenticatorGetLoggingDelegate(void) +{ + return wordPressAuthenticatorLogger; +} + +void WPAuthenticatorSetLoggingDelegate(id _Nullable logger) +{ + wordPressAuthenticatorLogger = logger; +} + +#define WPAuthenticatorLogv(logFunc) \ + ({ \ + id logger = WPAuthenticatorGetLoggingDelegate(); \ + if (logger == NULL) { \ + NSLog(@"[WordPressAuthenticator] Warning: please call `WPAuthenticatorSetLoggingDelegate` to set a error logger."); \ + return; \ + } \ + if (![logger respondsToSelector:@selector(logFunc)]) { \ + NSLog(@"[WordPressAuthenticator] Warning: %@ does not implement " #logFunc, logger); \ + return; \ + } \ + /* Originally `performSelector:withObject:` was used to call the logging function, but for unknown reason */ \ + /* it causes a crash on `objc_retain`. So I have to switch to this strange "syntax" to call the logging function directly. */ \ + [logger logFunc [[NSString alloc] initWithFormat:str arguments:args]]; \ + }) + +#define WPAuthenticatorLog(logFunc) \ + ({ \ + va_list args; \ + va_start(args, str); \ + WPAuthenticatorLogv(logFunc); \ + va_end(args); \ + }) + +void WPAuthenticatorLogError(NSString *str, ...) { WPAuthenticatorLog(logError:); } +void WPAuthenticatorLogWarning(NSString *str, ...) { WPAuthenticatorLog(logWarning:); } +void WPAuthenticatorLogInfo(NSString *str, ...) { WPAuthenticatorLog(logInfo:); } +void WPAuthenticatorLogDebug(NSString *str, ...) { WPAuthenticatorLog(logDebug:); } +void WPAuthenticatorLogVerbose(NSString *str, ...) { WPAuthenticatorLog(logVerbose:); } + +void WPAuthenticatorLogvError(NSString *str, va_list args) { WPAuthenticatorLogv(logError:); } +void WPAuthenticatorLogvWarning(NSString *str, va_list args) { WPAuthenticatorLogv(logWarning:); } +void WPAuthenticatorLogvInfo(NSString *str, va_list args) { WPAuthenticatorLogv(logInfo:); } +void WPAuthenticatorLogvDebug(NSString *str, va_list args) { WPAuthenticatorLogv(logDebug:); } +void WPAuthenticatorLogvVerbose(NSString *str, va_list args) { WPAuthenticatorLogv(logVerbose:); } diff --git a/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.swift b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.swift new file mode 100644 index 000000000000..3c91ca09a1a6 --- /dev/null +++ b/WordPressAuthenticator/Sources/Logging/WPAuthenticatorLogging.swift @@ -0,0 +1,19 @@ +func WPAuthenticatorLogError(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPAuthenticatorLogvError(format, $0) } +} + +func WPAuthenticatorLogWarning(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPAuthenticatorLogvWarning(format, $0) } +} + +func WPAuthenticatorLogInfo(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPAuthenticatorLogvInfo(format, $0) } +} + +func WPAuthenticatorLogDebug(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPAuthenticatorLogvDebug(format, $0) } +} + +func WPAuthenticatorLogVerbose(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPAuthenticatorLogvVerbose(format, $0) } +} diff --git a/WordPressAuthenticator/Sources/Model/LoginFields+Validation.swift b/WordPressAuthenticator/Sources/Model/LoginFields+Validation.swift new file mode 100644 index 000000000000..a22cd6cb1692 --- /dev/null +++ b/WordPressAuthenticator/Sources/Model/LoginFields+Validation.swift @@ -0,0 +1,47 @@ +// MARK: - LoginFields Validation Methods +// +extension LoginFields { + + /// Returns *true* if the fields required for SignIn have been populated. + /// Note: that loginFields.emailAddress is not checked. Use loginFields.username instead. + /// + func validateFieldsPopulatedForSignin() -> Bool { + return !username.isEmpty && + !password.isEmpty && + (meta.userIsDotCom || !siteAddress.isEmpty) + } + + /// Returns *true* if the siteURL contains a valid URL. False otherwise. + /// + func validateSiteForSignin() -> Bool { + guard let url = URL(string: NSURL.idnEncodedURL(siteAddress)) else { + return false + } + + return !url.absoluteString.isEmpty + } + + /// Returns *true* if the credentials required for account creation have been provided. + /// + func validateFieldsPopulatedForCreateAccount() -> Bool { + return !emailAddress.isEmpty && + !username.isEmpty && + !password.isEmpty && + !siteAddress.isEmpty + } + + /// Returns *true* if no spaces have been used in [email, username, address] + /// + func validateFieldsForSigninContainNoSpaces() -> Bool { + let space = " " + return !emailAddress.contains(space) && + !username.contains(space) && + !siteAddress.contains(space) + } + + /// Returns *true* if the username is 50 characters or less. + /// + func validateUsernameMaxLength() -> Bool { + return username.count <= 50 + } +} diff --git a/WordPressAuthenticator/Sources/Model/LoginFields.swift b/WordPressAuthenticator/Sources/Model/LoginFields.swift new file mode 100644 index 000000000000..10502dab3ad7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Model/LoginFields.swift @@ -0,0 +1,143 @@ +import Foundation +import WordPressKit + +/// LoginFields is a state container for user textfield input on the login screens +/// as well as other meta data regarding the nature of a login attempt. +/// +@objc +public class LoginFields: NSObject { + // These fields store user input from text fields. + + /// Stores the user's account identifier (either email address or username) that is + /// entered in the login flow. By convention, even if the user is logging in + /// via an email address this field should store that value. + @objc public var username = "" + + /// The user's password. + @objc public var password = "" + + /// The site address if logging in via the self-hosted flow. + @objc public var siteAddress = "" + + /// The two factor code entered by a user. + @objc public var multifactorCode = "" // 2fa code + + /// Nonce info in the event of a social login with 2fa + @objc public var nonceInfo: SocialLogin2FANonceInfo? + + /// User ID for use with the nonce for social login + @objc public var nonceUserID: Int = 0 + + /// Used to restrict login to WordPress.com + public var restrictToWPCom = false + + /// Used on the webauthn/security key flow. + public var webauthnChallengeInfo: WebauthnChallengeInfo? + + /// Used by the SignupViewController. Signup currently asks for both a + /// username and an email address. This can be factored away when we revamp + /// the signup flow. + @objc public var emailAddress = "" + + var meta = LoginFieldsMeta() + + @objc public var userIsDotCom: Bool { + get { meta.userIsDotCom } + set { meta.userIsDotCom = newValue } + } + + @objc public var requiredMultifactor: Bool { + meta.requiredMultifactor + } + + @objc public var xmlrpcURL: NSURL? { + get { meta.xmlrpcURL } + set { meta.xmlrpcURL = newValue } + } + + var storedCredentials: SafariStoredCredentials? + + /// Convenience method for persisting stored credentials. + /// + @objc func setStoredCredentials(usernameHash: Int, passwordHash: Int) { + storedCredentials = SafariStoredCredentials() + storedCredentials?.storedUserameHash = usernameHash + storedCredentials?.storedPasswordHash = passwordHash + } + + class func makeForWPCom(username: String, password: String) -> LoginFields { + let loginFields = LoginFields() + + loginFields.username = username + loginFields.password = password + + return loginFields + } + + /// Using a convenience initializer for its Objective-C usage in unit tests. + convenience init(username: String, + password: String, + siteAddress: String, + multifactorCode: String, + nonceInfo: SocialLogin2FANonceInfo?, + nonceUserID: Int, + restrictToWPCom: Bool, + emailAddress: String, + meta: LoginFieldsMeta, + storedCredentials: SafariStoredCredentials?) { + self.init() + self.username = username + self.password = password + self.siteAddress = siteAddress + self.multifactorCode = multifactorCode + self.nonceInfo = nonceInfo + self.nonceUserID = nonceUserID + self.restrictToWPCom = restrictToWPCom + self.emailAddress = emailAddress + self.meta = meta + self.storedCredentials = storedCredentials + } +} + +extension LoginFields { + func copy() -> LoginFields { + .init(username: username, + password: password, + siteAddress: siteAddress, + multifactorCode: multifactorCode, + nonceInfo: nonceInfo, + nonceUserID: nonceUserID, + restrictToWPCom: restrictToWPCom, + emailAddress: emailAddress, + meta: meta.copy(), + storedCredentials: storedCredentials) + } +} + +extension LoginFields { + + var parametersForSignInWithApple: [String: AnyObject]? { + guard let user = meta.socialUser, case .apple = user.service else { + return nil + } + + return AccountServiceRemoteREST.appleSignInParameters( + email: user.email, + fullName: user.fullName + ) + } +} + +/// A helper class for storing safari saved password information. +/// +class SafariStoredCredentials { + var storedUserameHash = 0 + var storedPasswordHash = 0 +} + +/// An enum to indicate where the Magic Link Email was sent from. +/// +enum EmailMagicLinkSource: Int { + case login = 1 + case signup = 2 +} diff --git a/WordPressAuthenticator/Sources/Model/LoginFieldsMeta.swift b/WordPressAuthenticator/Sources/Model/LoginFieldsMeta.swift new file mode 100644 index 000000000000..f9c0943fd3aa --- /dev/null +++ b/WordPressAuthenticator/Sources/Model/LoginFieldsMeta.swift @@ -0,0 +1,83 @@ +import WordPressKit + +class LoginFieldsMeta { + + /// Indicates where the Magic Link Email was sent from. + /// + var emailMagicLinkSource: EmailMagicLinkSource? + + /// Indicates whether a self-hosted user is attempting to log in to Jetpack + /// + var jetpackLogin: Bool + + /// Indicates whether a user is logging in via the wpcom flow or a self-hosted flow. Used by the + /// the LoginFacade in its branching logic. + /// This is a good candidate to refactor out and call the proper login method directly. + /// + var userIsDotCom: Bool + + /// Indicates a wpcom account created via social sign up that requires either a magic link, or a social log in option. + /// If a user signed up via social sign up and subsequently reset their password this field will be false. + /// + var passwordless: Bool + + /// Should point to the site's xmlrpc.php for a self-hosted log in. Should be the value returned via XML-RPC discovery. + /// + var xmlrpcURL: NSURL? + + /// Meta data about a site. This information is fetched and then displayed on the login epilogue. + /// + var siteInfo: WordPressComSiteInfo? + + /// Flags whether a 2FA challenge had to be satisfied before a log in could be complete. + /// Included in analytics after a successful login. + /// + /// A `false` value means that a 2FA prompt was needed. + /// + var requiredMultifactor: Bool + + /// Identifies a social login and the service used. + /// + var socialService: SocialServiceName? + + var socialServiceIDToken: String? + + var socialUser: SocialUser? + + init(emailMagicLinkSource: EmailMagicLinkSource? = nil, + jetpackLogin: Bool = false, + userIsDotCom: Bool = true, + passwordless: Bool = false, + xmlrpcURL: NSURL? = nil, + siteInfo: WordPressComSiteInfo? = nil, + requiredMultifactor: Bool = false, + socialService: SocialServiceName? = nil, + socialServiceIDToken: String? = nil, + socialUser: SocialUser? = nil) { + self.emailMagicLinkSource = emailMagicLinkSource + self.jetpackLogin = jetpackLogin + self.userIsDotCom = userIsDotCom + self.passwordless = passwordless + self.xmlrpcURL = xmlrpcURL + self.siteInfo = siteInfo + self.requiredMultifactor = requiredMultifactor + self.socialService = socialService + self.socialServiceIDToken = socialServiceIDToken + self.socialUser = socialUser + } +} + +extension LoginFieldsMeta { + func copy() -> LoginFieldsMeta { + .init(emailMagicLinkSource: emailMagicLinkSource, + jetpackLogin: jetpackLogin, + userIsDotCom: userIsDotCom, + passwordless: passwordless, + xmlrpcURL: xmlrpcURL, + siteInfo: siteInfo, + requiredMultifactor: requiredMultifactor, + socialService: socialService, + socialServiceIDToken: socialServiceIDToken, + socialUser: socialUser) + } +} diff --git a/WordPressAuthenticator/Sources/Model/WordPressComSiteInfo.swift b/WordPressAuthenticator/Sources/Model/WordPressComSiteInfo.swift new file mode 100644 index 000000000000..579237b256b5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Model/WordPressComSiteInfo.swift @@ -0,0 +1,61 @@ +import Foundation + +// MARK: - WordPress.com Site Info +// +public class WordPressComSiteInfo { + + /// Site's Name! + /// + public let name: String + + /// Tagline. + /// + public let tagline: String + + /// Public URL. + /// + public let url: String + + /// Indicates if Jetpack is available, or not. + /// + public let hasJetpack: Bool + + /// Indicates if Jetpack is active, or not. + /// + public let isJetpackActive: Bool + + /// Indicates if Jetpack is connected, or not. + /// + public let isJetpackConnected: Bool + + /// URL of the Site's Blavatar. + /// + public let icon: String + + /// Indicates whether the site is WordPressDotCom, or not. + /// + public let isWPCom: Bool + + /// Inidcates wheter the site is WordPress, or not. + /// + public let isWP: Bool + + /// Inidcates whether the site exists, or not. + /// + public let exists: Bool + + /// Initializes the current SiteInfo instance with a raw dictionary. + /// + public init(remote: [AnyHashable: Any]) { + name = remote["name"] as? String ?? "" + tagline = remote["description"] as? String ?? "" + url = remote["urlAfterRedirects"] as? String ?? "" + hasJetpack = remote["hasJetpack"] as? Bool ?? false + isJetpackActive = remote["isJetpackActive"] as? Bool ?? false + isJetpackConnected = remote["isJetpackConnected"] as? Bool ?? false + icon = remote["icon.img"] as? String ?? "" + isWPCom = remote["isWordPressDotCom"] as? Bool ?? false + isWP = remote["isWordPress"] as? Bool ?? false + exists = remote["exists"] as? Bool ?? false + } +} diff --git a/WordPressAuthenticator/Sources/NUX/Button/NUXButton.swift b/WordPressAuthenticator/Sources/NUX/Button/NUXButton.swift new file mode 100644 index 000000000000..9257cae6db88 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/Button/NUXButton.swift @@ -0,0 +1,267 @@ +import UIKit +import WordPressShared +import WordPressUI +import WordPressKit + +public struct NUXButtonStyle { + public let normal: ButtonStyle + public let highlighted: ButtonStyle + public let disabled: ButtonStyle + + public struct ButtonStyle { + public let backgroundColor: UIColor + public let borderColor: UIColor + public let titleColor: UIColor + + public init(backgroundColor: UIColor, borderColor: UIColor, titleColor: UIColor) { + self.backgroundColor = backgroundColor + self.borderColor = borderColor + self.titleColor = titleColor + } + } + + public init(normal: ButtonStyle, highlighted: ButtonStyle, disabled: ButtonStyle) { + self.normal = normal + self.highlighted = highlighted + self.disabled = disabled + } + + public static var linkButtonStyle: NUXButtonStyle { + let backgroundColor = UIColor.clear + let buttonTitleColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonColor ?? WordPressAuthenticator.shared.style.textButtonColor + let buttonHighlightColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonHighlightColor ?? WordPressAuthenticator.shared.style.textButtonHighlightColor + + let normalButtonStyle = ButtonStyle(backgroundColor: backgroundColor, + borderColor: backgroundColor, + titleColor: buttonTitleColor) + let highlightedButtonStyle = ButtonStyle(backgroundColor: backgroundColor, + borderColor: backgroundColor, + titleColor: buttonHighlightColor) + let disabledButtonStyle = ButtonStyle(backgroundColor: backgroundColor, + borderColor: backgroundColor, + titleColor: buttonTitleColor.withAlphaComponent(0.5)) + return NUXButtonStyle(normal: normalButtonStyle, + highlighted: highlightedButtonStyle, + disabled: disabledButtonStyle) + } +} +/// A stylized button used by Login controllers. It also can display a `UIActivityIndicatorView`. +@objc open class NUXButton: UIButton { + @objc var isAnimating: Bool { + return activityIndicator.isAnimating + } + + var buttonStyle: NUXButtonStyle? + + open override var isEnabled: Bool { + didSet { + activityIndicator.color = activityIndicatorColor(isEnabled: isEnabled) + } + } + + @objc let activityIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .medium) + indicator.hidesWhenStopped = true + return indicator + }() + + var titleFont = WPStyleGuide.mediumWeightFont(forStyle: .title3) + + override open func layoutSubviews() { + super.layoutSubviews() + + if activityIndicator.isAnimating { + titleLabel?.frame = CGRect.zero + + var frm = activityIndicator.frame + frm.origin.x = (frame.width - frm.width) / 2.0 + frm.origin.y = (frame.height - frm.height) / 2.0 + activityIndicator.frame = frm.integral + } + } + + open override func tintColorDidChange() { + // Update colors when toggling light/dark mode. + super.tintColorDidChange() + configureBackgrounds() + configureTitleColors() + + if socialService == .apple { + setAttributedTitle(WPStyleGuide.formattedAppleString(), for: .normal) + } + } + + // MARK: - Instance Methods + + /// Toggles the visibility of the activity indicator. When visible the button + /// title is hidden. + /// + /// - Parameter show: True to show the spinner. False hides it. + /// + open func showActivityIndicator(_ show: Bool) { + if show { + activityIndicator.startAnimating() + } else { + activityIndicator.stopAnimating() + } + setNeedsLayout() + } + + func didChangePreferredContentSize() { + titleLabel?.adjustsFontForContentSizeCategory = true + } + + func customizeFont(_ font: UIFont) { + titleFont = font + } + + /// Indicates if the current instance should be rendered with the "Primary" Style. + /// + @IBInspectable public var isPrimary: Bool = false { + didSet { + configureBackgrounds() + configureTitleColors() + } + } + + var socialService: SocialServiceName? + + // MARK: - LifeCycle Methods + + open override func didMoveToWindow() { + super.didMoveToWindow() + configureAppearance() + } + + open override func awakeFromNib() { + super.awakeFromNib() + configureAppearance() + } + + /// Setup: Everything = [Insets, Backgrounds, titleColor(s), titleLabel] + /// + private func configureAppearance() { + configureInsets() + configureBackgrounds() + configureActivityIndicator() + configureTitleColors() + configureTitleLabel() + } + + /// Setup: NUXButton's Default Settings + /// + private func configureInsets() { + contentEdgeInsets = UIImage.DefaultRenderMetrics.contentInsets + } + + /// Setup: ActivityIndicator + /// + private func configureActivityIndicator() { + activityIndicator.color = activityIndicatorColor() + addSubview(activityIndicator) + } + + /// Setup: BackgroundImage + /// + private func configureBackgrounds() { + guard let buttonStyle = buttonStyle else { + legacyConfigureBackgrounds() + return + } + + let normalImage = UIImage.renderBackgroundImage(fill: buttonStyle.normal.backgroundColor, + border: buttonStyle.normal.borderColor) + + let highlightedImage = UIImage.renderBackgroundImage(fill: buttonStyle.highlighted.backgroundColor, + border: buttonStyle.highlighted.borderColor) + + let disabledImage = UIImage.renderBackgroundImage(fill: buttonStyle.disabled.backgroundColor, + border: buttonStyle.disabled.borderColor) + + setBackgroundImage(normalImage, for: .normal) + setBackgroundImage(highlightedImage, for: .highlighted) + setBackgroundImage(disabledImage, for: .disabled) + } + + /// Fallback method to configure the background colors based on the shared `WordPressAuthenticatorStyle` + /// + private func legacyConfigureBackgrounds() { + let style = WordPressAuthenticator.shared.style + + let normalImage: UIImage + let highlightedImage: UIImage + let disabledImage = UIImage.renderBackgroundImage(fill: style.disabledBackgroundColor, + border: style.disabledBorderColor) + + if isPrimary { + normalImage = UIImage.renderBackgroundImage(fill: style.primaryNormalBackgroundColor, + border: style.primaryNormalBorderColor) + highlightedImage = UIImage.renderBackgroundImage(fill: style.primaryHighlightBackgroundColor, + border: style.primaryHighlightBorderColor) + } else { + normalImage = UIImage.renderBackgroundImage(fill: style.secondaryNormalBackgroundColor, + border: style.secondaryNormalBorderColor) + highlightedImage = UIImage.renderBackgroundImage(fill: style.secondaryHighlightBackgroundColor, + border: style.secondaryHighlightBorderColor) + } + + setBackgroundImage(normalImage, for: .normal) + setBackgroundImage(highlightedImage, for: .highlighted) + setBackgroundImage(disabledImage, for: .disabled) + } + + /// Setup: TitleColor + /// + private func configureTitleColors() { + guard let buttonStyle = buttonStyle else { + legacyConfigureTitleColors() + return + } + + setTitleColor(buttonStyle.normal.titleColor, for: .normal) + setTitleColor(buttonStyle.highlighted.titleColor, for: .highlighted) + setTitleColor(buttonStyle.disabled.titleColor, for: .disabled) + } + + /// Fallback method to configure the title colors based on the shared `WordPressAuthenticatorStyle` + /// + private func legacyConfigureTitleColors() { + let style = WordPressAuthenticator.shared.style + let titleColorNormal = isPrimary ? style.primaryTitleColor : style.secondaryTitleColor + + setTitleColor(titleColorNormal, for: .normal) + setTitleColor(titleColorNormal, for: .highlighted) + setTitleColor(style.disabledTitleColor, for: .disabled) + } + + /// Setup: TitleLabel + /// + private func configureTitleLabel() { + titleLabel?.font = self.titleFont + titleLabel?.adjustsFontForContentSizeCategory = true + titleLabel?.textAlignment = .center + } + + /// Returns the current color that should be used for the activity indicator + /// + private func activityIndicatorColor(isEnabled: Bool = true) -> UIColor { + guard let style = buttonStyle else { + let style = WordPressAuthenticator.shared.style + + return isEnabled ? style.primaryTitleColor : style.disabledButtonActivityIndicatorColor + } + + return isEnabled ? style.normal.titleColor : style.disabled.titleColor + } +} + +// MARK: - +// +extension NUXButton { + override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} diff --git a/WordPressAuthenticator/Sources/NUX/Button/NUXButtonView.storyboard b/WordPressAuthenticator/Sources/NUX/Button/NUXButtonView.storyboard new file mode 100644 index 000000000000..31db7e8cf3c6 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/Button/NUXButtonView.storyboard @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/NUX/Button/NUXButtonViewController.swift b/WordPressAuthenticator/Sources/NUX/Button/NUXButtonViewController.swift new file mode 100644 index 000000000000..55196bc554d3 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/Button/NUXButtonViewController.swift @@ -0,0 +1,310 @@ +import UIKit +import WordPressKit + +@objc public protocol NUXButtonViewControllerDelegate { + func primaryButtonPressed() + @objc optional func secondaryButtonPressed() + @objc optional func tertiaryButtonPressed() +} + + +struct NUXButtonConfig { + typealias CallBackType = () -> Void + + let title: String? + let attributedTitle: NSAttributedString? + let socialService: SocialServiceName? + let isPrimary: Bool + let configureBodyFontForTitle: Bool? + let accessibilityIdentifier: String? + let callback: CallBackType? + + init(title: String? = nil, attributedTitle: NSAttributedString? = nil, socialService: SocialServiceName? = nil, isPrimary: Bool, configureBodyFontForTitle: Bool? = nil, accessibilityIdentifier: String? = nil, callback: CallBackType?) { + self.title = title + self.attributedTitle = attributedTitle + self.socialService = socialService + self.isPrimary = isPrimary + self.configureBodyFontForTitle = configureBodyFontForTitle + self.accessibilityIdentifier = accessibilityIdentifier + self.callback = callback + } +} + +open class NUXButtonViewController: UIViewController { + typealias CallBackType = () -> Void + + // MARK: - Properties + + @IBOutlet var stackView: UIStackView? + @IBOutlet var bottomButton: NUXButton? + @IBOutlet var topButton: NUXButton? + @IBOutlet var tertiaryButton: NUXButton? + @IBOutlet var buttonHolder: UIView? + + @IBOutlet private var shadowView: UIImageView? + @IBOutlet private var shadowViewEdgeConstraints: [NSLayoutConstraint]! + + /// Used to constrain the shadow view outside of the + /// bounds of this view controller. + var shadowLayoutGuide: UILayoutGuide? { + didSet { + updateShadowViewEdgeConstraints() + } + } + + open weak var delegate: NUXButtonViewControllerDelegate? + open var backgroundColor: UIColor? + + private var topButtonConfig: NUXButtonConfig? + private var bottomButtonConfig: NUXButtonConfig? + private var tertiaryButtonConfig: NUXButtonConfig? + + public var topButtonStyle: NUXButtonStyle? + public var bottomButtonStyle: NUXButtonStyle? + public var tertiaryButtonStyle: NUXButtonStyle? + + private let style = WordPressAuthenticator.shared.style + + // MARK: - View + + override open func viewDidLoad() { + super.viewDidLoad() + view.translatesAutoresizingMaskIntoConstraints = false + + shadowView?.image = style.buttonViewTopShadowImage + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + configure(button: bottomButton, withConfig: bottomButtonConfig, and: bottomButtonStyle) + configure(button: topButton, withConfig: topButtonConfig, and: topButtonStyle) + configure(button: tertiaryButton, withConfig: tertiaryButtonConfig, and: tertiaryButtonStyle) + + buttonHolder?.backgroundColor = backgroundColor + } + + private func configure(button: NUXButton?, withConfig buttonConfig: NUXButtonConfig?, and style: NUXButtonStyle?) { + if let buttonConfig = buttonConfig, let button = button { + + if let attributedTitle = buttonConfig.attributedTitle { + button.setAttributedTitle(attributedTitle, for: .normal) + button.socialService = buttonConfig.socialService + } else { + button.setTitle(buttonConfig.title, for: .normal) + } + + button.accessibilityIdentifier = buttonConfig.accessibilityIdentifier ?? accessibilityIdentifier(for: buttonConfig.title) + button.isPrimary = buttonConfig.isPrimary + + if buttonConfig.configureBodyFontForTitle == true { + button.customizeFont(WPStyleGuide.mediumWeightFont(forStyle: .body)) + } + + button.buttonStyle = style + + button.isHidden = false + } else { + button?.isHidden = true + } + } + + private func updateShadowViewEdgeConstraints() { + guard let layoutGuide = shadowLayoutGuide, + let shadowView = shadowView else { + return + } + + NSLayoutConstraint.deactivate(shadowViewEdgeConstraints) + shadowView.translatesAutoresizingMaskIntoConstraints = false + + shadowViewEdgeConstraints = [ + layoutGuide.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor), + layoutGuide.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor), + ] + + NSLayoutConstraint.activate(shadowViewEdgeConstraints) + } + + // MARK: public API + + /// Public method to set the button titles. + /// + /// - Parameters: + /// - primary: Title string for primary button. Required. + /// - primaryAccessibilityId: Accessibility identifier string for primary button. Optional. + /// - secondary: Title string for secondary button. Optional. + /// - secondaryAccessibilityId: Accessibility identifier string for secondary button. Optional. + /// - tertiary: Title string for the tertiary button. Optional. + /// - tertiaryAccessibilityId: Accessibility identifier string for tertiary button. Optional. + /// + public func setButtonTitles(primary: String, primaryAccessibilityId: String? = nil, secondary: String? = nil, secondaryAccessibilityId: String? = nil, tertiary: String? = nil, tertiaryAccessibilityId: String? = nil) { + bottomButtonConfig = NUXButtonConfig(title: primary, isPrimary: true, accessibilityIdentifier: primaryAccessibilityId, callback: nil) + if let secondaryTitle = secondary { + topButtonConfig = NUXButtonConfig(title: secondaryTitle, isPrimary: false, accessibilityIdentifier: secondaryAccessibilityId, callback: nil) + } + if let tertiaryTitle = tertiary { + tertiaryButtonConfig = NUXButtonConfig(title: tertiaryTitle, isPrimary: false, accessibilityIdentifier: tertiaryAccessibilityId, callback: nil) + } + } + + func setupTopButton(title: String, isPrimary: Bool = false, configureBodyFontForTitle: Bool = false, accessibilityIdentifier: String? = nil, onTap callback: @escaping CallBackType) { + topButtonConfig = NUXButtonConfig(title: title, isPrimary: isPrimary, configureBodyFontForTitle: configureBodyFontForTitle, accessibilityIdentifier: accessibilityIdentifier, callback: callback) + } + + func setupTopButtonFor(socialService: SocialServiceName, onTap callback: @escaping CallBackType) { + topButtonConfig = buttonConfigFor(socialService: socialService, onTap: callback) + } + + func setupBottomButton(title: String, isPrimary: Bool = false, configureBodyFontForTitle: Bool = false, accessibilityIdentifier: String? = nil, onTap callback: @escaping CallBackType) { + bottomButtonConfig = NUXButtonConfig(title: title, isPrimary: isPrimary, configureBodyFontForTitle: configureBodyFontForTitle, accessibilityIdentifier: accessibilityIdentifier, callback: callback) + } + + // Sets up bottom button using `NSAttributedString` as title + // + func setupBottomButton(attributedTitle: NSAttributedString, + isPrimary: Bool = false, + configureBodyFontForTitle: Bool = false, + accessibilityIdentifier: String? = nil, + onTap callback: @escaping CallBackType) { + bottomButtonConfig = NUXButtonConfig(attributedTitle: attributedTitle, + isPrimary: isPrimary, + configureBodyFontForTitle: configureBodyFontForTitle, + accessibilityIdentifier: accessibilityIdentifier, + callback: callback) + } + + func setupButtomButtonFor(socialService: SocialServiceName, onTap callback: @escaping CallBackType) { + bottomButtonConfig = buttonConfigFor(socialService: socialService, onTap: callback) + } + + func setupTertiaryButton(attributedTitle: NSAttributedString, isPrimary: Bool = false, accessibilityIdentifier: String? = nil, onTap callback: @escaping CallBackType) { + tertiaryButton?.isHidden = false + tertiaryButtonConfig = NUXButtonConfig(attributedTitle: attributedTitle, isPrimary: isPrimary, accessibilityIdentifier: accessibilityIdentifier, callback: callback) + } + + func setupTertiaryButton(title: String, isPrimary: Bool = false, accessibilityIdentifier: String? = nil, onTap callback: @escaping CallBackType) { + tertiaryButton?.isHidden = false + tertiaryButtonConfig = NUXButtonConfig(title: title, isPrimary: isPrimary, accessibilityIdentifier: accessibilityIdentifier, callback: callback) + } + + func setupTertiaryButtonFor(socialService: SocialServiceName, onTap callback: @escaping CallBackType) { + tertiaryButtonConfig = buttonConfigFor(socialService: socialService, onTap: callback) + } + + func hideShadowView() { + shadowView?.isHidden = true + } + + public func setTopButtonState(isLoading: Bool, isEnabled: Bool) { + topButton?.showActivityIndicator(isLoading) + topButton?.isEnabled = isEnabled + } + + public func setBottomButtonState(isLoading: Bool, isEnabled: Bool) { + bottomButton?.showActivityIndicator(isLoading) + bottomButton?.isEnabled = isEnabled + } + + public func setTertiaryButtonState(isLoading: Bool, isEnabled: Bool) { + tertiaryButton?.showActivityIndicator(isLoading) + tertiaryButton?.isEnabled = isEnabled + } + + // MARK: - Helpers + + private func buttonConfigFor(socialService: SocialServiceName, onTap callback: @escaping CallBackType) -> NUXButtonConfig { + + var attributedTitle = NSAttributedString() + var accessibilityIdentifier = String() + + switch socialService { + case .google: + attributedTitle = WPStyleGuide.formattedGoogleString() + accessibilityIdentifier = "Continue with Google Button" + case .apple: + attributedTitle = WPStyleGuide.formattedAppleString() + accessibilityIdentifier = "Continue with Apple Button" + } + + return NUXButtonConfig(attributedTitle: attributedTitle, + socialService: socialService, + isPrimary: false, + accessibilityIdentifier: accessibilityIdentifier, + callback: callback) + } + + private func accessibilityIdentifier(for string: String?) -> String { + return "\(string ?? "") Button" + } + + // MARK: - Button Handling + + @IBAction func primaryButtonPressed(_ sender: Any) { + guard let callback = bottomButtonConfig?.callback else { + delegate?.primaryButtonPressed() + return + } + callback() + } + + @IBAction func secondaryButtonPressed(_ sender: Any) { + guard let callback = topButtonConfig?.callback else { + delegate?.secondaryButtonPressed?() + return + } + callback() + } + + @IBAction func tertiaryButtonPressed(_ sender: Any) { + guard let callback = tertiaryButtonConfig?.callback else { + delegate?.tertiaryButtonPressed?() + return + } + callback() + } + + // MARK: - Dynamic type + + func didChangePreferredContentSize() { + configure(button: bottomButton, withConfig: bottomButtonConfig, and: bottomButtonStyle) + configure(button: topButton, withConfig: topButtonConfig, and: topButtonStyle) + configure(button: tertiaryButton, withConfig: tertiaryButtonConfig, and: tertiaryButtonStyle) + } +} + +extension NUXButtonViewController { + override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} + +extension NUXButtonViewController { + + /// Sets the parentViewControlleras the receiver instance's container. Plus: the containerView will also get the receiver's + /// view, attached to it's edges. This is effectively analog to using an Embed Segue with the NUXButtonViewController. + /// + public func move(to parentViewController: UIViewController, into containerView: UIView) { + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(view) + containerView.pinSubviewToAllEdges(view) + + willMove(toParent: parentViewController) + parentViewController.addChild(self) + didMove(toParent: parentViewController) + } + + /// Returns a new NUXButtonViewController Instance + /// + public class func instance() -> NUXButtonViewController { + let storyboard = UIStoryboard(name: "NUXButtonView", bundle: WordPressAuthenticator.bundle) + guard let buttonViewController = storyboard.instantiateViewController(withIdentifier: "ButtonView") as? NUXButtonViewController else { + fatalError() + } + + return buttonViewController + } +} diff --git a/WordPressAuthenticator/Sources/NUX/Button/NUXStackedButtonsViewController.swift b/WordPressAuthenticator/Sources/NUX/Button/NUXStackedButtonsViewController.swift new file mode 100644 index 000000000000..c152beb78354 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/Button/NUXStackedButtonsViewController.swift @@ -0,0 +1,262 @@ +import UIKit + +struct StackedButton { + enum StackView { + case top + case bottom + } + + let stackView: StackView + let style: NUXButtonStyle? + + var config: NUXButtonConfig { + NUXButtonConfig(title: title, isPrimary: isPrimary, configureBodyFontForTitle: configureBodyFontForTitle, accessibilityIdentifier: accessibilityIdentifier, callback: onTap) + } + + // MARK: Private properties + private let title: String + private let isPrimary: Bool + private let configureBodyFontForTitle: Bool + private let accessibilityIdentifier: String? + private let onTap: NUXButtonConfig.CallBackType + + init(stackView: StackView = .top, + title: String, + isPrimary: Bool = false, + configureBodyFontForTitle: Bool = false, + accessibilityIdentifier: String? = nil, + style: NUXButtonStyle?, + onTap: @escaping NUXButtonConfig.CallBackType) { + self.stackView = stackView + self.title = title + self.isPrimary = isPrimary + self.configureBodyFontForTitle = configureBodyFontForTitle + self.accessibilityIdentifier = accessibilityIdentifier + self.style = style + self.onTap = onTap + } + + // MARK: Initializers + + /// Initializes a new StackedButton instance using the properties from the provided `StackedButton` and the provided `stackView` + /// + /// Used to copy properties of a StackedButton and just change the stackView placement + /// + /// - Parameters: + /// - using: StackedButton to be copied. (Except the `stackView` property) + /// - stackView: StackView placement of the new StackedButton + init(using: StackedButton, + stackView: StackView) { + self.init(stackView: stackView, + title: using.title, + isPrimary: using.isPrimary, + configureBodyFontForTitle: using.configureBodyFontForTitle, + accessibilityIdentifier: using.accessibilityIdentifier, + style: using.style, + onTap: using.onTap) + } +} + +/// Used to create two stack views of NUXButtons optionally divided by a OR divider +/// +/// Created as a replacement for NUXButtonViewController +/// +open class NUXStackedButtonsViewController: UIViewController { + // MARK: - Properties + @IBOutlet private weak var buttonHolder: UIView? + + // Stack view + @IBOutlet private var topStackView: UIStackView? + @IBOutlet private var bottomStackView: UIStackView? + + // Divider line + @IBOutlet private weak var leadingDividerLine: UIView! + @IBOutlet private weak var leadingDividerLineHeight: NSLayoutConstraint! + @IBOutlet private weak var dividerStackView: UIStackView! + @IBOutlet private weak var dividerLabel: UILabel! + @IBOutlet private weak var trailingDividerLine: UIView! + @IBOutlet private weak var trailingDividerLineHeight: NSLayoutConstraint! + + // Shadow + @IBOutlet private weak var shadowView: UIImageView? + @IBOutlet private var shadowViewEdgeConstraints: [NSLayoutConstraint]! + + /// Used to constrain the shadow view outside of the + /// bounds of this view controller. + weak var shadowLayoutGuide: UILayoutGuide? { + didSet { + updateShadowViewEdgeConstraints() + } + } + + var backgroundColor: UIColor? + private var showDivider = true + private var buttons: [NUXButton] = [] + + private let style = WordPressAuthenticator.shared.style + + private var buttonConfigs = [StackedButton]() + + // MARK: - View + override open func viewDidLoad() { + super.viewDidLoad() + view.translatesAutoresizingMaskIntoConstraints = false + + shadowView?.image = style.buttonViewTopShadowImage + configureDivider() + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + reloadViews() + + buttonHolder?.backgroundColor = backgroundColor + } + + // MARK: public API + func setUpButtons(using config: [StackedButton], showDivider: Bool) { + self.buttonConfigs = config + self.showDivider = showDivider + createButtons() + } + + func hideShadowView() { + shadowView?.isHidden = true + } +} + +// MARK: Helpers +// +private extension NUXStackedButtonsViewController { + @objc func handleTap(_ sender: NUXButton) { + guard let index = buttons.firstIndex(of: sender), + let callback = buttonConfigs[index].config.callback else { + return + } + + callback() + } + + func reloadViews() { + for (index, button) in buttons.enumerated() { + button.configure(withConfig: buttonConfigs[index].config, and: buttonConfigs[index].style) + button.addTarget(self, action: #selector(handleTap), for: .touchUpInside) + } + dividerStackView.isHidden = !showDivider + } + + func createButtons() { + buttons = [] + topStackView?.arrangedSubviews.forEach({ $0.removeFromSuperview() }) + bottomStackView?.arrangedSubviews.forEach({ $0.removeFromSuperview() }) + for config in buttonConfigs { + let button = NUXButton() + switch config.stackView { + case .top: + topStackView?.addArrangedSubview(button) + case .bottom: + bottomStackView?.addArrangedSubview(button) + } + button.configure(withConfig: config.config, and: config.style) + buttons.append(button) + } + } + + func configureDivider() { + guard showDivider else { + return dividerStackView.isHidden = true + } + + leadingDividerLine.backgroundColor = style.orDividerSeparatorColor + leadingDividerLineHeight.constant = WPStyleGuide.hairlineBorderWidth + trailingDividerLine.backgroundColor = style.orDividerSeparatorColor + trailingDividerLineHeight.constant = WPStyleGuide.hairlineBorderWidth + dividerLabel.textColor = style.orDividerTextColor + dividerLabel.text = NSLocalizedString("Or", comment: "Divider on initial auth view separating auth options.").localizedUppercase + } + + func updateShadowViewEdgeConstraints() { + guard let layoutGuide = shadowLayoutGuide, + let shadowView = shadowView else { + return + } + + NSLayoutConstraint.deactivate(shadowViewEdgeConstraints) + shadowView.translatesAutoresizingMaskIntoConstraints = false + + shadowViewEdgeConstraints = [ + layoutGuide.leadingAnchor.constraint(equalTo: shadowView.leadingAnchor), + layoutGuide.trailingAnchor.constraint(equalTo: shadowView.trailingAnchor), + ] + + NSLayoutConstraint.activate(shadowViewEdgeConstraints) + } + + // MARK: - Dynamic type + func didChangePreferredContentSize() { + reloadViews() + } +} + +extension NUXStackedButtonsViewController { + override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} + +extension NUXStackedButtonsViewController { + + /// Sets the parentViewControlleras the receiver instance's container. Plus: the containerView will also get the receiver's + /// view, attached to it's edges. This is effectively analog to using an Embed Segue with the NUXButtonViewController. + /// + public func move(to parentViewController: UIViewController, into containerView: UIView) { + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(view) + containerView.pinSubviewToAllEdges(view) + + willMove(toParent: parentViewController) + parentViewController.addChild(self) + didMove(toParent: parentViewController) + } + + /// Returns a new NUXButtonViewController Instance + /// + public class func instance() -> NUXStackedButtonsViewController { + guard let buttonViewController = Storyboard.nuxButtonView.instantiateViewController(ofClass: NUXStackedButtonsViewController.self) else { + fatalError("Cannot instantiate initial NUXStackedButtonsViewController from NUXButtonView.storyboard") + } + + return buttonViewController + } +} + +private extension NUXButton { + func configure(withConfig buttonConfig: NUXButtonConfig?, and style: NUXButtonStyle?) { + guard let buttonConfig = buttonConfig else { + isHidden = true + return + } + + if let attributedTitle = buttonConfig.attributedTitle { + setAttributedTitle(attributedTitle, for: .normal) + } else { + setTitle(buttonConfig.title, for: .normal) + } + + socialService = buttonConfig.socialService + accessibilityIdentifier = buttonConfig.accessibilityIdentifier ?? "\(buttonConfig.title ?? "") Button" + isPrimary = buttonConfig.isPrimary + + if buttonConfig.configureBodyFontForTitle == true { + customizeFont(WPStyleGuide.mediumWeightFont(forStyle: .body)) + } + + buttonStyle = style + + isHidden = false + } +} diff --git a/WordPressAuthenticator/Sources/NUX/ModalViewControllerPresenting.swift b/WordPressAuthenticator/Sources/NUX/ModalViewControllerPresenting.swift new file mode 100644 index 000000000000..dfd2e33bd8fb --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/ModalViewControllerPresenting.swift @@ -0,0 +1,5 @@ +protocol ModalViewControllerPresenting { + func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) +} + +extension UIViewController: ModalViewControllerPresenting {} diff --git a/WordPressAuthenticator/Sources/NUX/NUXKeyboardResponder.swift b/WordPressAuthenticator/Sources/NUX/NUXKeyboardResponder.swift new file mode 100644 index 000000000000..1aae5046627d --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXKeyboardResponder.swift @@ -0,0 +1,148 @@ +// The signin forms are centered, and then adjusted for the combined height of +// the status bar and navigation bar. -(20 + 44). +// If this value is changed be sure to update the storyboard for consistency. +let NUXKeyboardDefaultFormVerticalOffset: CGFloat = -64.0 + +/// A protocol and extension encapsulating common keyboard releated logic for +/// Signin controllers. +/// +public protocol NUXKeyboardResponder: AnyObject { + var bottomContentConstraint: NSLayoutConstraint? {get} + var verticalCenterConstraint: NSLayoutConstraint? {get} + + func signinFormVerticalOffset() -> CGFloat + func registerForKeyboardEvents(keyboardWillShowAction: Selector, keyboardWillHideAction: Selector) + func unregisterForKeyboardEvents() + func adjustViewForKeyboard(_ visibleKeyboard: Bool) + + func keyboardWillShow(_ notification: Foundation.Notification) + func keyboardWillHide(_ notification: Foundation.Notification) +} + +public extension NUXKeyboardResponder where Self: NUXViewController { + + /// Registeres the receiver for keyboard events using the passed selectors. + /// We pass the selectors this way so we can encapsulate functionality in a + /// Swift protocol extension and still play nice with Objective C code. + /// + /// - Parameters + /// - keyboardWillShowAction: A Selector to use for the UIKeyboardWillShowNotification observer. + /// - keyboardWillHideAction: A Selector to use for the UIKeyboardWillHideNotification observer. + /// + func registerForKeyboardEvents(keyboardWillShowAction: Selector, keyboardWillHideAction: Selector) { + NotificationCenter.default.addObserver(self, selector: keyboardWillShowAction, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: keyboardWillHideAction, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + /// Unregisters the receiver from keyboard events. + /// + func unregisterForKeyboardEvents() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + /// Returns the vertical offset to apply to the sign in form. + /// + /// - Returns: NUXKeyboardDefaultFormVerticalOffset unless a conforming controller provides its own implementation. + /// + func signinFormVerticalOffset() -> CGFloat { + return NUXKeyboardDefaultFormVerticalOffset + } + + /// Adjusts constraint constants to adapt the view for a visible keyboard. + /// + /// - Parameter visibleKeyboard: Whether to configure for a visible keyboard or without a keyboard. + /// + func adjustViewForKeyboard(_ visibleKeyboard: Bool) { + if visibleKeyboard && SigninEditingState.signinLastKeyboardHeightDelta > 0 { + bottomContentConstraint?.constant = SigninEditingState.signinLastKeyboardHeightDelta + verticalCenterConstraint?.constant = 0 + } else { + bottomContentConstraint?.constant = 0 + verticalCenterConstraint?.constant = signinFormVerticalOffset() + } + } + + /// Process the passed NSNotification from a UIKeyboardWillShowNotification. + /// + /// - Parameter notification: the NSNotification object from a UIKeyboardWillShowNotification. + /// + func keyboardWillShow(_ notification: Foundation.Notification) { + guard let keyboardInfo = keyboardFrameAndDurationFromNotification(notification) else { + return + } + + SigninEditingState.signinLastKeyboardHeightDelta = heightDeltaFromKeyboardFrame(keyboardInfo.keyboardFrame) + SigninEditingState.signinEditingStateActive = true + + if bottomContentConstraint?.constant == SigninEditingState.signinLastKeyboardHeightDelta { + return + } + + adjustViewForKeyboard(true) + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: .beginFromCurrentState, + animations: { + self.view.layoutIfNeeded() + }, + completion: nil) + } + + /// Process the passed NSNotification from a UIKeyboardWillHideNotification. + /// + /// - Parameter notification: the NSNotification object from a UIKeyboardWillHideNotification. + /// + func keyboardWillHide(_ notification: Foundation.Notification) { + guard let keyboardInfo = keyboardFrameAndDurationFromNotification(notification) else { + return + } + + SigninEditingState.signinEditingStateActive = false + + if bottomContentConstraint?.constant == 0 { + return + } + + adjustViewForKeyboard(false) + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: .beginFromCurrentState, + animations: { + self.view.layoutIfNeeded() + }, + completion: nil) + } + + /// Retrieves the keyboard frame and the animation duration from a keyboard + /// notificaiton. + /// + /// - Parameter notification: the NSNotification object from a keyboard notification. + /// + /// - Returns: An tupile optional containing the `keyboardFrame` and the `animationDuration`, or nil. + /// + func keyboardFrameAndDurationFromNotification(_ notification: Foundation.Notification) -> (keyboardFrame: CGRect, animationDuration: Double)? { + + guard let userInfo = notification.userInfo, + let frame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue, + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue + else { + return nil + } + return (keyboardFrame: frame, animationDuration: duration) + } + + func heightDeltaFromKeyboardFrame(_ keyboardFrame: CGRect) -> CGFloat { + // If an external keyboard is connected, the ending keyboard frame's maxY + // will exceed the height of the view controller's view. + // In these cases, just adjust the height by the amount of the keyboard visible. + if keyboardFrame.maxY > UIScreen.main.bounds.size.height { + return view.frame.height - keyboardFrame.minY + } + + // If the safe area has a bottom height, subtract that. + let bottomAdjust: CGFloat = view.safeAreaInsets.bottom + return keyboardFrame.height - bottomAdjust + } + +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXLinkAuthViewController.swift b/WordPressAuthenticator/Sources/NUX/NUXLinkAuthViewController.swift new file mode 100644 index 000000000000..cb8110a3a864 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXLinkAuthViewController.swift @@ -0,0 +1,44 @@ +import UIKit +import WordPressShared + +/// Handles the final step in the magic link auth process. At this point all the +/// necessary auth work should be done. We just need to create a WPAccount and to +/// sync account info and blog details. +/// The expectation is this controller will be momentarily visible when the app +/// is resumed/launched via the appropriate custom scheme, and quickly dismiss. +/// +class NUXLinkAuthViewController: LoginViewController { + @IBOutlet weak var statusLabel: UILabel? + + enum Flow { + case signup + case login + } + + /// Displays the specified text in the status label. + /// + /// - Parameter message: The text to display in the label. + /// + override func configureStatusLabel(_ message: String) { + statusLabel?.text = message + } + + func syncAndContinue(authToken: String, flow: Flow, isJetpackConnect: Bool) { + let wpcom = WordPressComCredentials(authToken: authToken, isJetpackLogin: isJetpackConnect, multifactor: false, siteURL: "https://wordpress.com") + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + syncWPComAndPresentEpilogue(credentials: credentials) { + self.tracker.track(step: .success) + + switch flow { + case .signup: + // This stat is part of a funnel that provides critical information. Before + // making ANY modification to this stat please refer to: p4qSXL-35X-p2 + WordPressAuthenticator.track(.createdAccount, properties: ["source": "email"]) + WordPressAuthenticator.track(.signupMagicLinkSucceeded) + case .login: + WordPressAuthenticator.track(.loginMagicLinkSucceeded) + } + } + } +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXLinkMailViewController.swift b/WordPressAuthenticator/Sources/NUX/NUXLinkMailViewController.swift new file mode 100644 index 000000000000..f0fc9a5897ff --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXLinkMailViewController.swift @@ -0,0 +1,128 @@ +import UIKit +import WordPressShared + +/// Step two in the auth link flow. This VC prompts the user to open their email +/// app to look for the emailed authentication link. +/// +class NUXLinkMailViewController: LoginViewController { + @IBOutlet private weak var imageView: UIImageView! + @IBOutlet var label: UILabel? + @IBOutlet var openMailButton: NUXButton? + @IBOutlet var usePasswordButton: UIButton? + var emailMagicLinkSource: EmailMagicLinkSource? + override var sourceTag: WordPressSupportSourceTag { + get { + if let emailMagicLinkSource = emailMagicLinkSource, + emailMagicLinkSource == .signup { + return .wpComSignupMagicLink + } + return .loginMagicLink + } + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + imageView.image = WordPressAuthenticator.shared.displayImages.magicLink + + let email = loginFields.username + if !email.isValidEmail() { + assert(email.isValidEmail(), "The value of loginFields.username was not a valid email address.") + } + + emailMagicLinkSource = loginFields.meta.emailMagicLinkSource + assert(emailMagicLinkSource != nil, "Must have an email link source.") + + styleUsePasswordButton() + localizeControls() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + } + + // MARK: - Configuration + + private func styleUsePasswordButton() { + guard let usePasswordButton = usePasswordButton else { + return + } + WPStyleGuide.configureTextButton(usePasswordButton) + } + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + + let openMailButtonTitle = NSLocalizedString("Open Mail", comment: "Title of a button. The text should be capitalized. Clicking opens the mail app in the user's iOS device.") + openMailButton?.setTitle(openMailButtonTitle, for: .normal) + openMailButton?.setTitle(openMailButtonTitle, for: .highlighted) + openMailButton?.accessibilityIdentifier = "Open Mail Button" + + let usePasswordTitle = NSLocalizedString("Enter your password instead.", comment: "Title of a button on the magic link screen.") + usePasswordButton?.setTitle(usePasswordTitle, for: .normal) + usePasswordButton?.setTitle(usePasswordTitle, for: .highlighted) + usePasswordButton?.titleLabel?.numberOfLines = 0 + usePasswordButton?.accessibilityIdentifier = "Use Password" + + guard let emailMagicLinkSource = emailMagicLinkSource else { + return + } + + usePasswordButton?.isHidden = emailMagicLinkSource == .signup + + label?.text = NSLocalizedString("Check your email on this device, and tap the link in the email you received from WordPress.com.\n\nNot seeing the email? Check your Spam or Junk Mail folder.", comment: "Instructional text on how to open the email containing a magic link.") + + label?.textColor = WordPressAuthenticator.shared.style.instructionColor + } + + // MARK: - Dynamic type + override func didChangePreferredContentSize() { + label?.font = WPStyleGuide.fontForTextStyle(.headline) + } + + // MARK: - Actions + + @IBAction func handleOpenMailTapped(_ sender: UIButton) { + defer { + if let emailMagicLinkSource = emailMagicLinkSource { + switch emailMagicLinkSource { + case .login: + WordPressAuthenticator.track(.loginMagicLinkOpenEmailClientViewed) + case .signup: + WordPressAuthenticator.track(.signupMagicLinkOpenEmailClientViewed) + } + } + } + + let linkMailPresenter = LinkMailPresenter(emailAddress: loginFields.username) + let appSelector = AppSelector(sourceView: sender) + linkMailPresenter.presentEmailClients(on: self, appSelector: appSelector) + } + + @IBAction func handleUsePasswordTapped(_ sender: UIButton) { + WordPressAuthenticator.track(.loginMagicLinkExited) + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginWPComViewController from NUXLinkMailViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } +} + +extension NUXLinkMailViewController { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXNavigationController.swift b/WordPressAuthenticator/Sources/NUX/NUXNavigationController.swift new file mode 100644 index 000000000000..3f3d7ac38fa8 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXNavigationController.swift @@ -0,0 +1,9 @@ +import UIKit +import WordPressUI + +/// Simple subclass of UINavigationController to facilitate a customized +/// appearance as part of the sign in flow. +/// +@objc open class NUXNavigationController: RotationAwareNavigationViewController { + +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXTableViewController.swift b/WordPressAuthenticator/Sources/NUX/NUXTableViewController.swift new file mode 100644 index 000000000000..a1e15db5ae0f --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXTableViewController.swift @@ -0,0 +1,46 @@ +// MARK: - NUXTableViewController +/// Base class to use for NUX view controllers that are also a table view controller +/// Note: shares most of its code with NUXViewController. +open class NUXTableViewController: UITableViewController, NUXViewControllerBase, UIViewControllerTransitioningDelegate { + // MARK: NUXViewControllerBase properties + /// these properties comply with NUXViewControllerBase and are duplicated with NUXViewController + public var helpNotificationIndicator: WPHelpIndicatorView = WPHelpIndicatorView() + public var helpButton: UIButton = UIButton(type: .custom) + public var dismissBlock: ((_ cancelled: Bool) -> Void)? + public var loginFields = LoginFields() + open var sourceTag: WordPressSupportSourceTag { + get { + return .generalLogin + } + } + + override open var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.isPad() ? .all : .portrait + } + + // MARK: - Private + private var notificationObservers: [NSObjectProtocol] = [] + + override open func viewDidLoad() { + super.viewDidLoad() + setupHelpButtonIfNeeded() + setupCancelButtonIfNeeded() + } + + public func shouldShowCancelButton() -> Bool { + return shouldShowCancelButtonBase() + } + + // MARK: - Notification Observers + + public func addNotificationObserver(_ observer: NSObjectProtocol) { + notificationObservers.append(observer) + } + + deinit { + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + notificationObservers.removeAll() + } +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXViewController.swift b/WordPressAuthenticator/Sources/NUX/NUXViewController.swift new file mode 100644 index 000000000000..8c538be3d484 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXViewController.swift @@ -0,0 +1,86 @@ +import WordPressUI + +// MARK: - NUXViewController +/// Base class to use for NUX view controllers that aren't a table view +/// Note: shares most of its code with NUXTableViewController. Look to make +/// most changes in either the base protocol NUXViewControllerBase or further subclasses like LoginViewController +open class NUXViewController: UIViewController, NUXViewControllerBase, UIViewControllerTransitioningDelegate { + // MARK: NUXViewControllerBase properties + /// these properties comply with NUXViewControllerBase and are duplicated with NUXTableViewController + public var helpNotificationIndicator: WPHelpIndicatorView = WPHelpIndicatorView() + public var helpButton: UIButton = UIButton(type: .custom) + public var dismissBlock: ((_ cancelled: Bool) -> Void)? + public var loginFields = LoginFields() + open var sourceTag: WordPressSupportSourceTag { + get { + return .generalLogin + } + } + + // MARK: - Private + private var notificationObservers: [NSObjectProtocol] = [] + + override open var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.isPad() ? .all : .portrait + } + + override open func viewDidLoad() { + super.viewDidLoad() + setupHelpButtonIfNeeded() + setupCancelButtonIfNeeded() + setupBackgroundTapGestureRecognizer() + } + + // properties specific to NUXViewController + @IBOutlet var submitButton: NUXButton? + @IBOutlet var errorLabel: UILabel? + + func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + submitButton?.isEnabled = enableSubmit(animating: animating) + } + + /// Localize the "Continue" button. + /// + func localizePrimaryButton() { + let primaryTitle = WordPressAuthenticator.shared.displayStrings.continueButtonTitle + submitButton?.setTitle(primaryTitle, for: .normal) + submitButton?.setTitle(primaryTitle, for: .highlighted) + submitButton?.accessibilityIdentifier = "Continue Button" + } + + open func enableSubmit(animating: Bool) -> Bool { + return !animating + } + + public func shouldShowCancelButton() -> Bool { + return shouldShowCancelButtonBase() + } + + // MARK: - Notification Observers + + public func addNotificationObserver(_ observer: NSObjectProtocol) { + notificationObservers.append(observer) + } + + deinit { + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + notificationObservers.removeAll() + } +} + +extension NUXViewController { + // Required so that any FancyAlertViewControllers presented within the NUX + // use the correct dimmed backing view. + open func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { + if presented is FancyAlertViewController || + presented is LoginPrologueSignupMethodViewController || + presented is LoginPrologueLoginMethodViewController { + return FancyAlertPresentationController(presentedViewController: presented, presenting: presenting) + } + + return nil + } +} diff --git a/WordPressAuthenticator/Sources/NUX/NUXViewControllerBase.swift b/WordPressAuthenticator/Sources/NUX/NUXViewControllerBase.swift new file mode 100644 index 000000000000..c40fa8e57d45 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/NUXViewControllerBase.swift @@ -0,0 +1,333 @@ +import Gridicons +import WordPressUI + +private enum Constants { + static let helpButtonInsets = UIEdgeInsets(top: 0.0, left: 5.0, bottom: 0.0, right: 5.0) + // Button Item: Custom view wrapping the Help UIbutton + static let helpButtonItemMarginSpace = CGFloat(-8) + static let helpButtonItemMinimumSize = CGSize(width: 44.0, height: 44.0) + + static let notificationIndicatorCenterOffset = CGPoint(x: 5, y: 12) + static var notificationIndicatorSize = CGSize(width: 10, height: 10) +} + +/// base protocol for NUX view controllers +public protocol NUXViewControllerBase { + var sourceTag: WordPressSupportSourceTag { get } + var helpNotificationIndicator: WPHelpIndicatorView { get } + var helpButton: UIButton { get } + var loginFields: LoginFields { get } + var dismissBlock: ((_ cancelled: Bool) -> Void)? { get } + + /// Checks if the signin vc modal should show a back button. The back button + /// visible when there is more than one child vc presented, and there is not + /// a case where a `SigninChildViewController.backButtonEnabled` in the stack + /// returns false. + /// + /// - Returns: True if the back button should be visible. False otherwise. + /// + func shouldShowCancelButton() -> Bool + func setupCancelButtonIfNeeded() + + /// Notification observers that can be tied to the lifecycle of the entities implementing the protocol + func addNotificationObserver(_ observer: NSObjectProtocol) +} + +/// extension for NUXViewControllerBase where the base class is UIViewController (and thus also NUXTableViewController) +extension NUXViewControllerBase where Self: UIViewController, Self: UIViewControllerTransitioningDelegate { + + /// Indicates if the Help Button should be displayed, or not. + /// + var shouldDisplayHelpButton: Bool { + return WordPressAuthenticator.shared.delegate?.supportActionEnabled ?? false + } + + /// Indicates if the Cancel button should be displayed, or not. + /// + func shouldShowCancelButtonBase() -> Bool { + return isCancellable() && navigationController?.viewControllers.first == self + } + + /// Sets up the cancel button for the navbar if its needed. + /// The cancel button is only shown when its appropriate to dismiss the modal view controller. + /// + public func setupCancelButtonIfNeeded() { + if !shouldShowCancelButton() { + return + } + + let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: nil, action: nil) + cancelButton.on { [weak self] (_: UIBarButtonItem) in + self?.handleCancelButtonTapped() + } + navigationItem.leftBarButtonItem = cancelButton + } + + /// Returns true whenever the current ViewController can be dismissed. + /// + func isCancellable() -> Bool { + return WordPressAuthenticator.shared.delegate?.dismissActionEnabled ?? true + } + + /// Displays a login error in an attractive dialog + /// + func displayError(_ error: Error, sourceTag: WordPressSupportSourceTag) { + let presentingController = navigationController ?? self + let controller = FancyAlertViewController.alertForError(error, loginFields: loginFields, sourceTag: sourceTag) + controller.modalPresentationStyle = .custom + controller.transitioningDelegate = self + presentingController.present(controller, animated: true, completion: nil) + } + + /// Displays a login error message in an attractive dialog + /// + public func displayErrorAlert(_ message: String, sourceTag: WordPressSupportSourceTag, onDismiss: (() -> ())? = nil) { + let presentingController = navigationController ?? self + let controller = FancyAlertViewController.alertForGenericErrorMessageWithHelpButton(message, loginFields: loginFields, sourceTag: sourceTag, onDismiss: onDismiss) + controller.modalPresentationStyle = .custom + controller.transitioningDelegate = self + presentingController.present(controller, animated: true, completion: nil) + } + + /// It is assumed that NUX view controllers are always presented modally. + /// + func dismiss() { + dismiss(cancelled: false) + } + + /// It is assumed that NUX view controllers are always presented modally. + /// This method dismisses the view controller + /// + /// - Parameters: + /// - cancelled: Should be passed true only when dismissed by a tap on the cancel button. + /// + fileprivate func dismiss(cancelled: Bool) { + dismissBlock?(cancelled) + self.dismiss(animated: true, completion: nil) + } + + // MARK: - Notifications + + /// Updates the notification indicatorand its visibility. + /// + func refreshSupportNotificationIndicator() { + let showIndicator = WordPressAuthenticator.shared.delegate?.showSupportNotificationIndicator ?? false + helpNotificationIndicator.isHidden = !showIndicator + } + + // MARK: - Actions + + func handleBackgroundTapGesture() { + view.endEditing(true) + } + + func setupBackgroundTapGestureRecognizer() { + let tgr = UITapGestureRecognizer() + tgr.on { [weak self] _ in + self?.handleBackgroundTapGesture() + } + view.addGestureRecognizer(tgr) + } + + func handleCancelButtonTapped() { + dismiss(cancelled: true) + NotificationCenter.default.post(name: .wordpressLoginCancelled, object: nil) + } + + // Handle the help button being tapped + // + func handleHelpButtonTapped(_ sender: AnyObject) { + AuthenticatorAnalyticsTracker.shared.track(click: .showHelp) + + displaySupportViewController(from: sourceTag) + } + + // MARK: - Navbar Help and App Logo methods + + func styleNavigationBar(forUnified: Bool = false) { + var backgroundColor: UIColor + var buttonTextColor: UIColor + var titleTextColor: UIColor + var hideBottomBorder: Bool + + if forUnified { + // Unified nav bar style + backgroundColor = WordPressAuthenticator.shared.unifiedStyle?.navBarBackgroundColor ?? + WordPressAuthenticator.shared.style.navBarBackgroundColor + buttonTextColor = WordPressAuthenticator.shared.unifiedStyle?.navButtonTextColor ?? + WordPressAuthenticator.shared.style.navButtonTextColor + titleTextColor = WordPressAuthenticator.shared.unifiedStyle?.navTitleTextColor ?? + WordPressAuthenticator.shared.style.primaryTitleColor + hideBottomBorder = true + } else { + // Original nav bar style + backgroundColor = WordPressAuthenticator.shared.style.navBarBackgroundColor + buttonTextColor = WordPressAuthenticator.shared.style.navButtonTextColor + titleTextColor = WordPressAuthenticator.shared.style.primaryTitleColor + hideBottomBorder = false + } + + setupNavBarIcon(showIcon: !forUnified) + setHelpButtonTextColor(forUnified: forUnified) + + let buttonItemAppearance = UIBarButtonItem.appearance(whenContainedInInstancesOf: [LoginNavigationController.self]) + buttonItemAppearance.tintColor = buttonTextColor + buttonItemAppearance.setTitleTextAttributes([.foregroundColor: buttonTextColor], for: .normal) + + let appearance = UINavigationBarAppearance() + appearance.shadowColor = hideBottomBorder ? .clear : .separator + appearance.backgroundColor = backgroundColor + appearance.titleTextAttributes = [.foregroundColor: titleTextColor] + + UINavigationBar.appearance(whenContainedInInstancesOf: [LoginNavigationController.self]).standardAppearance = appearance + UINavigationBar.appearance(whenContainedInInstancesOf: [LoginNavigationController.self]).compactAppearance = appearance + UINavigationBar.appearance(whenContainedInInstancesOf: [LoginNavigationController.self]).scrollEdgeAppearance = appearance + } + + /// Add/remove the nav bar app logo. + /// + func setupNavBarIcon(showIcon: Bool = true) { + showIcon ? addAppLogoToNavController() : removeAppLogoFromNavController() + } + + /// Adds the app logo to the nav controller + /// + public func addAppLogoToNavController() { + let image = WordPressAuthenticator.shared.style.navBarImage + let imageView = UIImageView(image: image.imageWithTintColor(UIColor.white)) + navigationItem.titleView = imageView + } + + /// Removes the app logo from the nav controller + /// + public func removeAppLogoFromNavController() { + navigationItem.titleView = nil + } + + /// Whenever the WordPressAuthenticator Delegate returns true, when `shouldDisplayHelpButton` is queried, we'll proceed + /// and attach the Help Button to the navigationController. + /// + func setupHelpButtonIfNeeded() { + guard shouldDisplayHelpButton else { + return + } + + addHelpButtonToNavController() + refreshSupportNotificationIndicator() + } + + /// Sets the Help button text color. + /// + /// - Parameters: + /// - forUnified: Indicates whether to use text color for the unified auth flows or the original auth flows. + /// + func setHelpButtonTextColor(forUnified: Bool) { + let navButtonTextColor: UIColor = { + if forUnified { + return WordPressAuthenticator.shared.unifiedStyle?.navButtonTextColor ?? WordPressAuthenticator.shared.style.navButtonTextColor + } + return WordPressAuthenticator.shared.style.navButtonTextColor + }() + + helpButton.setTitleColor(navButtonTextColor, for: .normal) + helpButton.setTitleColor(navButtonTextColor.withAlphaComponent(0.4), for: .highlighted) + } + + // MARK: - Helpers + + /// Adds the Help Button to the nav controller + /// + private func addHelpButtonToNavController() { + let barButtonView = createBarButtonView() + addHelpButton(to: barButtonView) + addNotificationIndicatorView(to: barButtonView) + addRightBarButtonItem(with: barButtonView) + } + + private func addRightBarButtonItem(with customView: UIView) { + let spacer = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + spacer.width = Constants.helpButtonItemMarginSpace + + let barButton = UIBarButtonItem(customView: customView) + navigationItem.rightBarButtonItems = [spacer, barButton] + } + + private func createBarButtonView() -> UIView { + let customView = UIView(frame: .zero) + customView.translatesAutoresizingMaskIntoConstraints = false + customView.heightAnchor.constraint(equalToConstant: Constants.helpButtonItemMinimumSize.height).isActive = true + customView.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.helpButtonItemMinimumSize.width).isActive = true + + return customView + } + + private func addHelpButton(to superView: UIView) { + helpButton.setTitle(NSLocalizedString("Help", comment: "Help button"), for: .normal) + setHelpButtonTextColor(forUnified: false) + helpButton.accessibilityIdentifier = "authenticator-help-button" + + helpButton.on(.touchUpInside) { [weak self] control in + self?.handleHelpButtonTapped(control) + } + + superView.addSubview(helpButton) + helpButton.translatesAutoresizingMaskIntoConstraints = false + + helpButton.leadingAnchor.constraint(equalTo: superView.leadingAnchor, constant: Constants.helpButtonInsets.left).isActive = true + helpButton.trailingAnchor.constraint(equalTo: superView.trailingAnchor, constant: -Constants.helpButtonInsets.right).isActive = true + helpButton.topAnchor.constraint(equalTo: superView.topAnchor).isActive = true + helpButton.bottomAnchor.constraint(equalTo: superView.bottomAnchor).isActive = true + } + + // MARK: Notification Indicator settings + + private func addNotificationIndicatorView(to superView: UIView) { + setupNotificationsIndicator() + layoutNotificationIndicatorView(helpNotificationIndicator, to: superView) + } + + private func setupNotificationsIndicator() { + helpNotificationIndicator.isHidden = true + + addNotificationObserver( + NotificationCenter.default.addObserver(forName: .wordpressSupportNotificationReceived, object: nil, queue: nil) { [weak self] _ in + self?.refreshSupportNotificationIndicator() + } + ) + + addNotificationObserver( + NotificationCenter.default.addObserver(forName: .wordpressSupportNotificationCleared, object: nil, queue: nil) { [weak self] _ in + self?.refreshSupportNotificationIndicator() + } + ) + } + + private func layoutNotificationIndicatorView(_ view: UIView, to superView: UIView) { + superView.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + + let centerOffset = Constants.notificationIndicatorCenterOffset + let xConstant = helpButton.contentEdgeInsets.top + centerOffset.x + let yConstant = helpButton.contentEdgeInsets.top + centerOffset.y + + NSLayoutConstraint.activate([ + view.centerXAnchor.constraint(equalTo: helpButton.trailingAnchor, constant: xConstant), + view.centerYAnchor.constraint(equalTo: helpButton.topAnchor, constant: yConstant), + view.widthAnchor.constraint(equalToConstant: Constants.notificationIndicatorSize.width), + view.heightAnchor.constraint(equalToConstant: Constants.notificationIndicatorSize.height) + ]) + } + + // MARK: - UIViewControllerTransitioningDelegate + + /// Displays the support vc. + /// + func displaySupportViewController(from source: WordPressSupportSourceTag) { + guard let navigationController = navigationController else { + fatalError() + } + + let state = AuthenticatorAnalyticsTracker.shared.state + WordPressAuthenticator.shared.delegate?.presentSupport(from: navigationController, sourceTag: source, lastStep: state.lastStep, lastFlow: state.lastFlow) + } +} diff --git a/WordPressAuthenticator/Sources/NUX/WPHelpIndicatorView.swift b/WordPressAuthenticator/Sources/NUX/WPHelpIndicatorView.swift new file mode 100644 index 000000000000..da5d858243f2 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPHelpIndicatorView.swift @@ -0,0 +1,36 @@ +import UIKit +import WordPressShared + +open class WPHelpIndicatorView: UIView { + + struct Constants { + static let defaultInsets = UIEdgeInsets.zero + static let defaultBackgroundColor = WordPressAuthenticator.shared.style.navBarBadgeColor + } + + var insets: UIEdgeInsets = Constants.defaultInsets { + didSet { + setNeedsDisplay() + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + commonSetup() + } + + public required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func commonSetup() { + layer.masksToBounds = true + layer.cornerRadius = 6.0 + backgroundColor = Constants.defaultBackgroundColor + } + + override open func draw(_ rect: CGRect) { + super.draw(rect.inset(by: insets)) + } + +} diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.h b/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.h new file mode 100644 index 000000000000..f747f6035aad --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.h @@ -0,0 +1,8 @@ +#import + +@interface WPNUXMainButton : UIButton + +- (void)showActivityIndicator:(BOOL)show; +- (void)setColor:(UIColor *)color; + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.m b/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.m new file mode 100644 index 000000000000..2445907a350e --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXMainButton.m @@ -0,0 +1,80 @@ +#import "WPNUXMainButton.h" +#import + +@implementation WPNUXMainButton { + UIActivityIndicatorView *activityIndicator; +} + +- (id)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + [self configureButton]; + } + return self; +} + +- (id)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) { + [self configureButton]; + } + return self; +} + +- (void)layoutSubviews +{ + + [super layoutSubviews]; + if ([activityIndicator isAnimating]) { + + // hide the title label when the activity indicator is visible + self.titleLabel.frame = CGRectZero; + activityIndicator.frame = CGRectMake((self.frame.size.width - activityIndicator.frame.size.width) / 2.0, (self.frame.size.height - activityIndicator.frame.size.height) / 2.0, activityIndicator.frame.size.width, activityIndicator.frame.size.height); + } +} + +- (void)configureButton +{ + [self setTitle:NSLocalizedString(@"Log In", nil) forState:UIControlStateNormal]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.9] forState:UIControlStateNormal]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.4] forState:UIControlStateDisabled]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.4] forState:UIControlStateHighlighted]; + self.titleLabel.font = [WPFontManager systemRegularFontOfSize:18.0]; + [self setColor:[UIColor colorWithRed:0/255.0f green:116/255.0f blue:162/255.0f alpha:1.0f]]; + + activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + activityIndicator.hidesWhenStopped = YES; + [self addSubview:activityIndicator]; +} + +- (void)showActivityIndicator:(BOOL)show +{ + if (show) { + [activityIndicator startAnimating]; + } else { + [activityIndicator stopAnimating]; + } + [self setNeedsLayout]; +} + +- (void)setColor:(UIColor *)color +{ + CGRect fillRect = CGRectMake(0, 0, 11.0, 40.0); + UIEdgeInsets capInsets = UIEdgeInsetsMake(4, 4, 4, 4); + UIImage *mainImage; + + UIGraphicsBeginImageContextWithOptions(fillRect.size, NO, [[UIScreen mainScreen] scale]); + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetFillColorWithColor(context, color.CGColor); + CGContextAddPath(context, [UIBezierPath bezierPathWithRoundedRect:fillRect cornerRadius:3.0].CGPath); + CGContextClip(context); + CGContextFillRect(context, fillRect); + mainImage = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + [self setBackgroundImage:[mainImage resizableImageWithCapInsets:capInsets] forState:UIControlStateNormal]; +} + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.h b/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.h new file mode 100644 index 000000000000..e9fb2a4ca43d --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.h @@ -0,0 +1,5 @@ +#import + +@interface WPNUXPrimaryButton : UIButton + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.m b/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.m new file mode 100644 index 000000000000..a91eaa24a490 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXPrimaryButton.m @@ -0,0 +1,58 @@ +#import "WPNUXPrimaryButton.h" +#import + +@implementation WPNUXPrimaryButton + +- (id)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + [self configureButton]; + } + return self; +} + +- (id)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) { + [self configureButton]; + } + return self; +} + +- (void)sizeToFit +{ + [super sizeToFit]; + + // Adjust frame to account for the edge insets + CGRect frame = self.frame; + frame.size.width += self.titleEdgeInsets.left + self.titleEdgeInsets.right; + self.frame = frame; +} + +- (CGSize)intrinsicContentSize +{ + CGSize size = [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; + size.width += self.titleEdgeInsets.left + self.titleEdgeInsets.right; + return size; +} + +#pragma mark - Private Methods + +- (void)configureButton +{ + UIImage *mainImage = [[UIImage imageNamed:@"btn-primary"] resizableImageWithCapInsets:UIEdgeInsetsMake(0, 4, 0, 4)]; + UIImage *tappedImage = [[UIImage imageNamed:@"btn-primary-tap"] resizableImageWithCapInsets:UIEdgeInsetsMake(0, 4, 0, 4)]; + self.titleLabel.font = [WPFontManager systemRegularFontOfSize:15.0]; + self.titleLabel.minimumScaleFactor = 10.0/15.0; + [self setTitleEdgeInsets:UIEdgeInsetsMake(0, 15.0, 0, 15.0)]; + [self setBackgroundImage:mainImage forState:UIControlStateNormal]; + [self setBackgroundImage:tappedImage forState:UIControlStateHighlighted]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.9] forState:UIControlStateNormal]; + [self setTitleColor:[UIColor colorWithRed:25.0/255.0 green:135.0/255.0 blue:179.0/255.0 alpha:1.0] forState:UIControlStateHighlighted]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.6] forState:UIControlStateDisabled]; + +} + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.h b/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.h new file mode 100644 index 000000000000..d653fc3cc831 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.h @@ -0,0 +1,5 @@ +#import + +@interface WPNUXSecondaryButton : UIButton + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.m b/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.m new file mode 100644 index 000000000000..d2857ab78d47 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPNUXSecondaryButton.m @@ -0,0 +1,57 @@ +#import "WPNUXSecondaryButton.h" +#import + + +static UIEdgeInsets const WPNUXSecondaryButtonTitleEdgeInsets = {0, 15.0, 0, 15.0}; + + +@implementation WPNUXSecondaryButton + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + [self configureButton]; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) { + [self configureButton]; + } + return self; +} + +- (void)sizeToFit +{ + [super sizeToFit]; + + // Adjust frame to account for the edge insets + CGRect frame = self.frame; + frame.size.width += self.titleEdgeInsets.left + self.titleEdgeInsets.right; + self.frame = frame; +} + +- (CGSize)intrinsicContentSize +{ + CGSize size = [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; + size.width += self.titleEdgeInsets.left + self.titleEdgeInsets.right; + return size; +} + +#pragma mark - Private Methods + +- (void)configureButton +{ + self.titleLabel.font = [WPFontManager systemRegularFontOfSize:15.0]; + self.titleLabel.minimumScaleFactor = 10.0/15.0; + self.titleLabel.adjustsFontSizeToFitWidth = YES; + [self setTitleEdgeInsets:WPNUXSecondaryButtonTitleEdgeInsets]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:1.0] forState:UIControlStateNormal]; + [self setTitleColor:[UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.4] forState:UIControlStateHighlighted]; +} + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.h b/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.h new file mode 100644 index 000000000000..cca69b6ec626 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.h @@ -0,0 +1,31 @@ +#import + +typedef NS_ENUM(NSUInteger, WPWalkthroughOverlayViewOverlayMode) { + WPWalkthroughGrayOverlayViewOverlayModeTapToDismiss, + WPWalkthroughGrayOverlayViewOverlayModeTwoButtonMode, + WPWalkthroughGrayOverlayViewOverlayModePrimaryButton +}; + +typedef NS_ENUM(NSUInteger, WPWalkthroughOverlayViewIcon) { + WPWalkthroughGrayOverlayViewWarningIcon, + WPWalkthroughGrayOverlayViewBlueCheckmarkIcon, +}; + +@interface WPWalkthroughOverlayView : UIView + +@property (nonatomic, assign) WPWalkthroughOverlayViewOverlayMode overlayMode; +@property (nonatomic, assign) WPWalkthroughOverlayViewIcon icon; +@property (nonatomic, strong) NSString *overlayTitle; +@property (nonatomic, strong) NSString *overlayDescription; +@property (nonatomic, strong) NSString *footerDescription; +@property (nonatomic, strong) NSString *secondaryButtonText; +@property (nonatomic, strong) NSString *primaryButtonText; +@property (nonatomic, assign) BOOL hideBackgroundView; + +@property (nonatomic, copy) void (^dismissCompletionBlock)(WPWalkthroughOverlayView *); +@property (nonatomic, copy) void (^secondaryButtonCompletionBlock)(WPWalkthroughOverlayView *); +@property (nonatomic, copy) void (^primaryButtonCompletionBlock)(WPWalkthroughOverlayView *); + +- (void)dismiss; + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.m b/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.m new file mode 100644 index 000000000000..204bf3a64f40 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPWalkthroughOverlayView.m @@ -0,0 +1,329 @@ +#import "WPWalkthroughOverlayView.h" +#import "WPNUXPrimaryButton.h" +#import "WPNUXSecondaryButton.h" +#import +#import +#import + +@interface WPWalkthroughOverlayView() { + UIImageView *_logo; + UILabel *_title; + UILabel *_description; + UILabel *_bottomLabel; + WPNUXSecondaryButton *_secondaryButton; + WPNUXPrimaryButton *_primaryButton; + + CGFloat _viewWidth; + CGFloat _viewHeight; + + UITapGestureRecognizer *_gestureRecognizer; +} + +@end + +@implementation WPWalkthroughOverlayView + +CGFloat const WPWalkthroughGrayOverlayIconVerticalOffset = 75.0; +CGFloat const WPWalkthroughGrayOverlayStandardOffset = 16.0; +CGFloat const WPWalkthroughGrayOverlayBottomLabelOffset = 91.0; +CGFloat const WPWalkthroughGrayOverlayBottomPanelHeight = 64.0; +CGFloat const WPWalkthroughGrayOverlayMaxLabelWidth = 289.0; + +- (id)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + _overlayMode = WPWalkthroughGrayOverlayViewOverlayModePrimaryButton; + + self.accessibilityViewIsModal = YES; + + [self configureBackgroundColor]; + [self addViewElements]; + [self addGestureRecognizer]; + [self setPrimaryButtonText:NSLocalizedString(@"OK", nil)]; + } + return self; +} + +- (void)setOverlayMode:(WPWalkthroughOverlayViewOverlayMode)overlayMode +{ + if (_overlayMode != overlayMode) { + _overlayMode = overlayMode; + [self adjustOverlayDismissal]; + [self setNeedsLayout]; + } +} + +- (void)setOverlayTitle:(NSString *)overlayTitle +{ + if (_overlayTitle != overlayTitle) { + _overlayTitle = overlayTitle; + _title.text = _overlayTitle; + [self setNeedsLayout]; + } +} + +- (void)setOverlayDescription:(NSString *)overlayDescription +{ + if (_overlayDescription != overlayDescription) { + _overlayDescription = overlayDescription; + _description.text = _overlayDescription; + [self setNeedsLayout]; + } +} + +- (void)setFooterDescription:(NSString *)footerDescription +{ + if (_footerDescription != footerDescription) { + _footerDescription = footerDescription; + _bottomLabel.text = _footerDescription; + [self setNeedsLayout]; + } +} + +- (void)setSecondaryButtonText:(NSString *)leftButtonText +{ + if (_secondaryButtonText != leftButtonText) { + _secondaryButtonText = leftButtonText; + [_secondaryButton setTitle:_secondaryButtonText forState:UIControlStateNormal]; + [_secondaryButton sizeToFit]; + [self setNeedsLayout]; + } +} + +- (void)setPrimaryButtonText:(NSString *)rightButtonText +{ + if (_primaryButtonText != rightButtonText) { + _primaryButtonText = rightButtonText; + [_primaryButton setTitle:_primaryButtonText forState:UIControlStateNormal]; + [_primaryButton sizeToFit]; + [self setNeedsLayout]; + } +} + +- (void)setIcon:(WPWalkthroughOverlayViewIcon)icon +{ + if (_icon != icon) { + _icon = icon; + [self configureIcon]; + [self setNeedsLayout]; + } +} + +- (void)setHideBackgroundView:(BOOL)hideBackgroundView +{ + if (_hideBackgroundView != hideBackgroundView) { + _hideBackgroundView = hideBackgroundView; + [self configureBackgroundColor]; + [self setNeedsLayout]; + } +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + _viewWidth = CGRectGetWidth(self.bounds); + _viewHeight = CGRectGetHeight(self.bounds); + + CGFloat x, y; + + // Layout Logo + [self configureIcon]; + x = (_viewWidth - CGRectGetWidth(_logo.frame))/2.0; + y = WPWalkthroughGrayOverlayIconVerticalOffset; + _logo.frame = CGRectIntegral(CGRectMake(x, y, CGRectGetWidth(_logo.frame), CGRectGetHeight(_logo.frame))); + + // Layout Title + CGSize titleSize = [_title suggestedSizeForWidth:WPWalkthroughGrayOverlayMaxLabelWidth]; + x = (_viewWidth - titleSize.width)/2.0; + y = CGRectGetMaxY(_logo.frame) + 0.5*WPWalkthroughGrayOverlayStandardOffset; + _title.frame = CGRectIntegral(CGRectMake(x, y, titleSize.width, titleSize.height)); + + // Layout Description + CGSize labelSize = [_description suggestedSizeForWidth:WPWalkthroughGrayOverlayMaxLabelWidth]; + x = (_viewWidth - labelSize.width)/2.0; + y = CGRectGetMaxY(_title.frame) + 0.5*WPWalkthroughGrayOverlayStandardOffset; + _description.frame = CGRectIntegral(CGRectMake(x, y, labelSize.width, labelSize.height)); + + // Layout Bottom Label + CGSize bottomLabelSize = [_bottomLabel.text sizeWithAttributes:@{NSFontAttributeName:_bottomLabel.font}]; + x = (_viewWidth - bottomLabelSize.width)/2.0; + y = _viewHeight - WPWalkthroughGrayOverlayBottomLabelOffset; + _bottomLabel.frame = CGRectIntegral(CGRectMake(x, y, bottomLabelSize.width, bottomLabelSize.height)); + + // Layout Bottom Buttons + if (self.overlayMode == WPWalkthroughGrayOverlayViewOverlayModePrimaryButton || + self.overlayMode == WPWalkthroughGrayOverlayViewOverlayModeTwoButtonMode) { + + x = _viewWidth - CGRectGetWidth(_primaryButton.frame) - WPWalkthroughGrayOverlayStandardOffset; + y = (_viewHeight - WPWalkthroughGrayOverlayBottomPanelHeight + WPWalkthroughGrayOverlayStandardOffset); + _primaryButton.frame = CGRectIntegral(CGRectMake(x, y, CGRectGetWidth(_primaryButton.frame), CGRectGetHeight(_primaryButton.frame))); + } else { + _primaryButton.frame = CGRectZero; + } + + if (self.overlayMode == WPWalkthroughGrayOverlayViewOverlayModeTwoButtonMode) { + + x = WPWalkthroughGrayOverlayStandardOffset; + y = (_viewHeight - WPWalkthroughGrayOverlayBottomPanelHeight + WPWalkthroughGrayOverlayStandardOffset); + _secondaryButton.frame = CGRectIntegral(CGRectMake(x, y, CGRectGetWidth(_secondaryButton.frame), CGRectGetHeight(_secondaryButton.frame))); + } else { + _secondaryButton.frame = CGRectZero; + } + + CGFloat heightFromBottomLabel = _viewHeight - CGRectGetMinY(_bottomLabel.frame) - CGRectGetHeight(_bottomLabel.frame); + NSArray *viewsToCenter = @[_logo, _title, _description]; + [WPNUXUtility centerViews:viewsToCenter withStartingView:_logo andEndingView:_description forHeight:(_viewHeight-heightFromBottomLabel)]; +} + +- (void)didMoveToSuperview +{ + [super didMoveToSuperview]; + + if (UIAccessibilityIsVoiceOverRunning()) { + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, _title); + } +} + +- (void)dismiss +{ + [self removeFromSuperview]; +} + +#pragma mark - Private Methods + +- (void)configureBackgroundColor +{ + CGFloat alpha = 0.95; + if (self.hideBackgroundView) { + alpha = 1.0; + } + self.backgroundColor = [UIColor colorWithRed:17.0/255.0 green:17.0/255.0 blue:17.0/255.0 alpha:alpha]; +} + +- (void)addGestureRecognizer +{ + _gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tappedOnView:)]; + _gestureRecognizer.numberOfTapsRequired = 1; + _gestureRecognizer.cancelsTouchesInView = NO; + [self addGestureRecognizer:_gestureRecognizer]; +} + +- (void)addViewElements +{ + // Add Icon + _logo = [[UIImageView alloc] init]; + [self configureIcon]; + [self addSubview:_logo]; + + // Add Title + _title = [[UILabel alloc] init]; + _title.backgroundColor = [UIColor clearColor]; + _title.textAlignment = NSTextAlignmentCenter; + _title.numberOfLines = 0; + _title.lineBreakMode = NSLineBreakByWordWrapping; + _title.font = [WPFontManager systemLightFontOfSize:25.0]; + _title.text = self.overlayTitle; + _title.shadowColor = [UIColor blackColor]; + _title.shadowOffset = CGSizeMake(1.0, 1.0); + _title.textColor = [UIColor whiteColor]; + [self addSubview:_title]; + + // Add Description + _description = [[UILabel alloc] init]; + _description.backgroundColor = [UIColor clearColor]; + _description.textAlignment = NSTextAlignmentCenter; + _description.numberOfLines = 0; + _description.lineBreakMode = NSLineBreakByWordWrapping; + _description.font = [WPNUXUtility descriptionTextFont]; + _description.text = self.overlayDescription; + _description.shadowColor = [UIColor blackColor]; + _description.textColor = [UIColor whiteColor]; + [self addSubview:_description]; + + // Add Bottom Label + _bottomLabel = [[UILabel alloc] init]; + _bottomLabel.backgroundColor = [UIColor clearColor]; + _bottomLabel.textAlignment = NSTextAlignmentCenter; + _bottomLabel.numberOfLines = 1; + _bottomLabel.font = [WPFontManager systemRegularFontOfSize:10.0]; + _bottomLabel.text = self.footerDescription; + _bottomLabel.textColor = [UIColor colorWithRed:1.0 green:1.0 blue:1.0 alpha:0.4]; + [self addSubview:_bottomLabel]; + + // Add Button 1 + _secondaryButton = [[WPNUXSecondaryButton alloc] init]; + [_secondaryButton setTitle:self.secondaryButtonText forState:UIControlStateNormal]; + [_secondaryButton sizeToFit]; + [_secondaryButton addTarget:self action:@selector(secondaryButtonAction) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_secondaryButton]; + + // Add Button 2 + _primaryButton = [[WPNUXPrimaryButton alloc] init]; + [_primaryButton setTitle:self.primaryButtonText forState:UIControlStateNormal]; + [_primaryButton sizeToFit]; + [_primaryButton addTarget:self action:@selector(primaryButtonAction) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:_primaryButton]; +} + +- (void)configureIcon +{ + UIImage *image; + if (self.icon == WPWalkthroughGrayOverlayViewWarningIcon) { + image = [UIImage imageNamed:@"icon-alert"]; + } else { + image = [UIImage imageNamed:@"icon-check-blue"]; + } + [_logo setImage:image]; + [_logo sizeToFit]; +} + +- (void)adjustOverlayDismissal +{ + // We always want a tap on the view to dismiss + _gestureRecognizer.numberOfTapsRequired = 1; +} + +- (void)tappedOnView:(UITapGestureRecognizer *)gestureRecognizer +{ + CGPoint touchPoint = [gestureRecognizer locationInView:self]; + + // To avoid accidentally dismissing the view when the user was trying to tap one of the buttons, + // add some padding around the button frames. + CGRect button1Frame = CGRectInset(_secondaryButton.frame, -2 * WPWalkthroughGrayOverlayStandardOffset, -WPWalkthroughGrayOverlayStandardOffset); + CGRect button2Frame = CGRectInset(_primaryButton.frame, -2 * WPWalkthroughGrayOverlayStandardOffset, -WPWalkthroughGrayOverlayStandardOffset); + + BOOL touchedButton1 = CGRectContainsPoint(button1Frame, touchPoint); + BOOL touchedButton2 = CGRectContainsPoint(button2Frame, touchPoint); + + if (touchedButton1 || touchedButton2) { + return; + } + + if (gestureRecognizer.numberOfTapsRequired == 1) { + if (self.dismissCompletionBlock) { + self.dismissCompletionBlock(self); + } + } +} + +- (void)secondaryButtonAction +{ + if (self.secondaryButtonCompletionBlock) { + self.secondaryButtonCompletionBlock(self); + } else if (self.dismissCompletionBlock) { + self.dismissCompletionBlock(self); + } +} + +- (void)primaryButtonAction +{ + if (self.primaryButtonCompletionBlock) { + self.primaryButtonCompletionBlock(self); + } else if (self.dismissCompletionBlock) { + self.dismissCompletionBlock(self); + } +} + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.h b/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.h new file mode 100644 index 000000000000..c70d572c4b56 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.h @@ -0,0 +1,43 @@ +#import + +IB_DESIGNABLE +@interface WPWalkthroughTextField : UITextField + +@property (nonatomic) IBInspectable BOOL showTopLineSeparator; +@property (nonatomic) IBInspectable BOOL showSecureTextEntryToggle; +@property (nonatomic) IBInspectable UIImage *leftViewImage; +@property (nonatomic) IBInspectable UIColor *secureTextEntryImageColor; + +/// Width for the left view. Set to 0 to use the given frame in the view. +/// Default is: 30 +/// +@property (nonatomic) CGFloat leadingViewWidth; + +/// Width for the right view. Set to 0 to use the given frame in the view. +/// Default is: 40 +/// +@property (nonatomic) CGFloat trailingViewWidth; + +/// Insets around the text area. +/// This value is mirrored in Right-to-Left layout +/// +@property (nonatomic) UIEdgeInsets textInsets; + +/// Insets around the leading (left) view. +/// This value is mirrored in Right-to-Left layout +/// +@property (nonatomic) UIEdgeInsets leadingViewInsets; + +/// Insets around the trailing (right) view. +/// This value is mirrored in Right-to-Left layout +/// +@property (nonatomic) UIEdgeInsets trailingViewInsets; + +/// Insets around the whole content of the textfield. +/// This value is mirrored in Right-to-Left layout +/// +@property (nonatomic) UIEdgeInsets contentInsets; + +- (instancetype)initWithLeftViewImage:(UIImage *)image; + +@end diff --git a/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.m b/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.m new file mode 100644 index 000000000000..03051dd0bf15 --- /dev/null +++ b/WordPressAuthenticator/Sources/NUX/WPWalkthroughTextField.m @@ -0,0 +1,296 @@ +#import "WPWalkthroughTextField.h" +#import + +NSInteger const LeftImageSpacing = 8; + +@import Gridicons; + +@interface WPWalkthroughTextField () +@property (nonatomic, strong) UIButton *secureTextEntryToggle; +@property (nonatomic, strong) UIImage *secureTextEntryImageVisible; +@property (nonatomic, strong) UIImage *secureTextEntryImageHidden; +@end + +@implementation WPWalkthroughTextField + +- (instancetype)init +{ + self = [super init]; + if (self) { + [self commonInit]; + } + return self; +} + +- (instancetype)initWithLeftViewImage:(UIImage *)image +{ + self = [self init]; + if (self) { + self.leftViewImage = image; + } + return self; +} + +- (instancetype)initWithCoder:(NSCoder *)aDecoder +{ + self = [super initWithCoder:aDecoder]; + if (self) { + [self commonInit]; + } + return self; +} + +- (void)setLeftViewImage:(UIImage *)leftViewImage +{ + if (leftViewImage) { + _leftViewImage = leftViewImage; + UIImageView *imageView = [[UIImageView alloc] initWithImage:leftViewImage]; + if (self.leadingViewWidth > 0) { + imageView.frame = [self frameForLeadingView]; + imageView.contentMode = [self isLayoutLeftToRight] ? UIViewContentModeLeft : UIViewContentModeRight; + } else { + [imageView sizeToFit]; + } + self.leftView = imageView; + self.leftViewMode = UITextFieldViewModeAlways; + } else { + self.leftView = nil; + } +} + +-(void)setRightView:(UIView *)rightView +{ + if (self.trailingViewWidth > 0) { + rightView.frame = [self frameForTrailingView]; + rightView.contentMode = [self isLayoutLeftToRight] ? UIViewContentModeRight : UIViewContentModeLeft; + if ([rightView isKindOfClass:[UIButton class]]) { + UIButton *button = (UIButton *)rightView; + if ([self isLayoutLeftToRight]) { + [button setContentHorizontalAlignment:UIControlContentHorizontalAlignmentRight]; + } else { + [button setContentHorizontalAlignment:UIControlContentHorizontalAlignmentLeft]; + } + } + } + [super setRightView:rightView]; +} + +- (void)setShowSecureTextEntryToggle:(BOOL)showSecureTextEntryToggle +{ + _showSecureTextEntryToggle = showSecureTextEntryToggle; + [self configureSecureTextEntryToggle]; +} + +- (void)commonInit +{ + self.leadingViewWidth = 30.f; + self.trailingViewWidth = 40.f; + + self.layer.cornerRadius = 0.0; + self.clipsToBounds = YES; + self.showTopLineSeparator = NO; + self.showSecureTextEntryToggle = NO; + + // Apply styles to the placeholder if one was set in IB. + if (self.placeholder) { + // colors here are overridden in LoginTextField + NSDictionary *attributes = @{ + NSForegroundColorAttributeName : WPStyleGuide.greyLighten10, + NSFontAttributeName : self.font, + }; + self.attributedPlaceholder = [[NSAttributedString alloc] initWithString:self.placeholder attributes:attributes]; + } + + self.leadingViewInsets = UIEdgeInsetsMake(0, 0, 0, LeftImageSpacing); +} + +- (void)awakeFromNib { + [super awakeFromNib]; + [self configureSecureTextEntryToggle]; +} + +- (void)configureSecureTextEntryToggle { + if (self.showSecureTextEntryToggle == NO) { + return; + } + self.secureTextEntryImageVisible = [UIImage gridiconOfType:GridiconTypeVisible]; + self.secureTextEntryImageHidden = [UIImage gridiconOfType:GridiconTypeNotVisible]; + + self.secureTextEntryToggle = [UIButton buttonWithType:UIButtonTypeCustom]; + self.secureTextEntryToggle.clipsToBounds = true; + + // Tint color changes set in LoginTextField. + + [self.secureTextEntryToggle addTarget:self action:@selector(secureTextEntryToggleAction:) forControlEvents:UIControlEventTouchUpInside]; + + [self updateSecureTextEntryToggleImage]; + [self updateSecureTextEntryForAccessibility]; + + self.rightView = self.secureTextEntryToggle; + self.rightViewMode = UITextFieldViewModeAlways; +} + +- (CGSize)intrinsicContentSize +{ + return CGSizeMake(0.0, 44.0); +} + +- (void)drawRect:(CGRect)rect +{ + // Draw top border + if (!self.showTopLineSeparator) { + return; + } + + CGContextRef context = UIGraphicsGetCurrentContext(); + + UIBezierPath *path = [UIBezierPath bezierPath]; + CGFloat emptySpace = self.contentInsets.left; + if ([self isLayoutLeftToRight]) { + [path moveToPoint:CGPointMake(CGRectGetMinX(rect) + emptySpace, CGRectGetMinY(rect))]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(rect), CGRectGetMinY(rect))]; + } else { + [path moveToPoint:CGPointMake(CGRectGetMinX(rect), CGRectGetMinY(rect))]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(rect) - emptySpace, CGRectGetMinY(rect))]; + } + + [path setLineWidth:[[UIScreen mainScreen] scale] / 2.0]; + CGContextAddPath(context, path.CGPath); + CGContextSetStrokeColorWithColor(context, [UIColor colorWithWhite:0.87 alpha:1.0].CGColor); + CGContextStrokePath(context); +} + + +/// Returns the drawing rectangle for the text field’s text. +/// +- (CGRect)textRectForBounds:(CGRect)bounds +{ + CGRect rect = [super textRectForBounds:bounds]; + return [self textAreaRectForProposedRect:rect]; +} + +/// Returns the rectangle in which editable text can be displayed. +/// +- (CGRect)editingRectForBounds:(CGRect)bounds +{ + CGRect rect = [super editingRectForBounds:bounds]; + return [self textAreaRectForProposedRect:rect]; +} + +/// Returns the drawing rectangle of the receiver’s left overlay view. +/// This value is always the view seen at the left side, independently of the layout direction. +/// +- (CGRect)leftViewRectForBounds:(CGRect)bounds +{ + CGRect rect = [super leftViewRectForBounds:bounds]; + if ([self isLayoutLeftToRight]) { + rect.origin.x += self.leadingViewInsets.left + self.contentInsets.left; + } else { + rect.origin.x += self.trailingViewInsets.right + self.contentInsets.right; + } + return rect; +} + +/// Returns the drawing location of the receiver’s right overlay view. +/// This value is always the view seen at the right side, independently of the layout direction. +/// +- (CGRect)rightViewRectForBounds:(CGRect)bounds +{ + CGRect rect = [super rightViewRectForBounds:bounds]; + if ([self isLayoutLeftToRight]) { + rect.origin.x -= self.trailingViewInsets.right + self.contentInsets.right; + } else { + rect.origin.x -= self.leadingViewInsets.left + self.contentInsets.left; + } + return rect; +} + +#pragma mark - Helpers + +- (BOOL)isLayoutLeftToRight +{ + return [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.semanticContentAttribute] == UIUserInterfaceLayoutDirectionLeftToRight; +} + +/// Returns the rectangle in which both editable text and the placeholder can be displayed. +/// +- (CGRect)textAreaRectForProposedRect:(CGRect)rect +{ + rect.size.width -= self.textInsets.left + self.textInsets.right; + if ([self isLayoutLeftToRight]) { + rect.origin.x += self.textInsets.left + self.leadingViewInsets.right; + rect.size.width -= self.leadingViewInsets.right + self.contentInsets.right; + if (self.leftView == nil) { + rect.origin.x += self.contentInsets.left; + rect.size.width -= self.contentInsets.right; + } + } else { + rect.origin.x += self.textInsets.right + self.trailingViewInsets.left; + rect.size.width -= self.leadingViewInsets.right + self.trailingViewInsets.left; + if (self.rightView == nil) { + rect.origin.x += self.contentInsets.right; + rect.size.width -= self.contentInsets.left; + } + if (self.leftView == nil) { + rect.size.width -= self.contentInsets.left; + } + } + return rect; +} + +- (CGRect)frameForTrailingView +{ + return CGRectMake(0, 0, self.trailingViewWidth, CGRectGetHeight(self.bounds)); +} + +- (CGRect)frameForLeadingView +{ + return CGRectMake(0, 0, self.leadingViewWidth, CGRectGetHeight(self.bounds)); +} + +#pragma mark - Secure Text Entry + +- (void)setSecureTextEntry:(BOOL)secureTextEntry +{ + // This is a fix for a bug where the text field reverts to a system + // serif font if you disable secure text entry while it contains text. + self.font = nil; + self.font = [WPNUXUtility textFieldFont]; + + [super setSecureTextEntry:secureTextEntry]; + [self updateSecureTextEntryToggleImage]; + [self updateSecureTextEntryForAccessibility]; +} + +- (void)secureTextEntryToggleAction:(id)sender +{ + self.secureTextEntry = !self.secureTextEntry; + + // Save and re-apply the current selection range to save the cursor position + UITextRange *currentTextRange = self.selectedTextRange; + [self becomeFirstResponder]; + [self setSelectedTextRange:currentTextRange]; +} + +- (void)updateSecureTextEntryToggleImage +{ + UIImage *image = self.isSecureTextEntry ? self.secureTextEntryImageHidden : self.secureTextEntryImageVisible; + [self.secureTextEntryToggle setImage:image forState:UIControlStateNormal]; + [self.secureTextEntryToggle sizeToFit]; + self.secureTextEntryToggle.tintColor = self.secureTextEntryImageColor; +} + +- (void)updateSecureTextEntryForAccessibility +{ + self.secureTextEntryToggle.accessibilityLabel = NSLocalizedString(@"Show password", @"Accessibility label for the “Show password“ button in the login page's password field."); + + NSString *accessibilityValue; + if (self.isSecureTextEntry) { + accessibilityValue = NSLocalizedString(@"Hidden", "Accessibility value if login page's password field is hiding the password (i.e. with asterisks)."); + } else { + accessibilityValue = NSLocalizedString(@"Shown", "Accessibility value if login page's password field is displaying the password."); + } + self.secureTextEntryToggle.accessibilityValue = accessibilityValue; +} + +@end diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateBack.swift b/WordPressAuthenticator/Sources/Navigation/NavigateBack.swift new file mode 100644 index 000000000000..0608c0da09c4 --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateBack.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Navigates back one step. +/// +public struct NavigateBack: NavigationCommand { + public init() {} + public func execute(from: UIViewController?) { + pop(navigationController: from?.navigationController) + } +} + +private extension NavigateBack { + func pop(navigationController: UINavigationController?) { + navigationController?.popViewController(animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateToEnterAccount.swift b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterAccount.swift new file mode 100644 index 000000000000..7d8783fad58f --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterAccount.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Navigates to the unified "Continue with WordPress.com" flow. +/// +public struct NavigateToEnterAccount: NavigationCommand { + private let signInSource: SignInSource + private let email: String? + + public init(signInSource: SignInSource, email: String? = nil) { + self.signInSource = signInSource + self.email = email + } + + public func execute(from: UIViewController?) { + continueWithDotCom(email: email, navigationController: from?.navigationController) + } +} + +private extension NavigateToEnterAccount { + private func continueWithDotCom(email: String? = nil, navigationController: UINavigationController?) { + guard let vc = GetStartedViewController.instantiate(from: .getStarted) else { + WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to GetStartedViewController") + return + } + vc.source = signInSource + vc.loginFields.username = email ?? "" + + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSite.swift b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSite.swift new file mode 100644 index 000000000000..32d21eba3383 --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSite.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Navigates to the unified site address login flow. +/// +public struct NavigateToEnterSite: NavigationCommand { + public init() {} + public func execute(from: UIViewController?) { + presentUnifiedSiteAddressView(navigationController: from?.navigationController) + } +} + +private extension NavigateToEnterSite { + func presentUnifiedSiteAddressView(navigationController: UINavigationController?) { + guard let vc = SiteAddressViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to SiteAddressViewController") + return + } + + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSiteCredentials.swift b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSiteCredentials.swift new file mode 100644 index 000000000000..cb1819a1da46 --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterSiteCredentials.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Navigates to the wp-admin site credentials flow. +/// +public struct NavigateToEnterSiteCredentials: NavigationCommand { + private let loginFields: LoginFields + + public init(loginFields: LoginFields) { + self.loginFields = loginFields + } + public func execute(from: UIViewController?) { + let navigationController = (from as? UINavigationController) ?? from?.navigationController + presentSiteCredentialsView(navigationController: navigationController, + loginFields: loginFields) + } +} + +private extension NavigateToEnterSiteCredentials { + func presentSiteCredentialsView(navigationController: UINavigationController?, loginFields: LoginFields) { + guard let controller = SiteCredentialsViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate to SiteCredentialsViewController") + return + } + + controller.loginFields = loginFields + navigationController?.pushViewController(controller, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateToEnterWPCOMPassword.swift b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterWPCOMPassword.swift new file mode 100644 index 000000000000..802306dcc79c --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateToEnterWPCOMPassword.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Navigates to the WPCOM password flow. +/// +public struct NavigateToEnterWPCOMPassword: NavigationCommand { + private let loginFields: LoginFields + + public init(loginFields: LoginFields) { + self.loginFields = loginFields + } + public func execute(from: UIViewController?) { + let navigationController = (from as? UINavigationController) ?? from?.navigationController + presentPasswordView(navigationController: navigationController, + loginFields: loginFields) + } +} + +private extension NavigateToEnterWPCOMPassword { + func presentPasswordView(navigationController: UINavigationController?, loginFields: LoginFields) { + guard let controller = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("Failed to navigate to PasswordViewController from GetStartedViewController") + return + } + + controller.loginFields = loginFields + navigationController?.pushViewController(controller, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigateToRoot.swift b/WordPressAuthenticator/Sources/Navigation/NavigateToRoot.swift new file mode 100644 index 000000000000..a4c6fd48e89d --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigateToRoot.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Navigates to the root of the unified login flow. +/// +public struct NavigateToRoot: NavigationCommand { + public init() {} + public func execute(from: UIViewController?) { + presentUnifiedSiteAddressView(navigationController: from?.navigationController) + } +} + +private extension NavigateToRoot { + func presentUnifiedSiteAddressView(navigationController: UINavigationController?) { + navigationController?.popToRootViewController(animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Navigation/NavigationCommand.swift b/WordPressAuthenticator/Sources/Navigation/NavigationCommand.swift new file mode 100644 index 000000000000..f6653c571d69 --- /dev/null +++ b/WordPressAuthenticator/Sources/Navigation/NavigationCommand.swift @@ -0,0 +1,10 @@ +import Foundation + +/// NavigationCommand abstracts logic necessary provide clients of this library +/// with a way to navigate to a particular location in the UL navigation flow. +/// +/// Concrete implementations of this protocol will decide what that means +/// +public protocol NavigationCommand { + func execute(from: UIViewController?) +} diff --git a/WordPressAuthenticator/Sources/Private/WPAuthenticator-Swift.h b/WordPressAuthenticator/Sources/Private/WPAuthenticator-Swift.h new file mode 100644 index 000000000000..a6e267a2aa72 --- /dev/null +++ b/WordPressAuthenticator/Sources/Private/WPAuthenticator-Swift.h @@ -0,0 +1,8 @@ +// Import this header instead of +// This allows the pod to be built as a static or dynamic framework +// See https://github.com/CocoaPods/CocoaPods/issues/7594 +#if __has_include("WordPressAuthenticator-Swift.h") + #import "WordPressAuthenticator-Swift.h" +#else + #import +#endif diff --git a/WordPressAuthenticator/Sources/Resources/Animations/jetpack.json b/WordPressAuthenticator/Sources/Resources/Animations/jetpack.json new file mode 100644 index 000000000000..4509ea16b8d9 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Animations/jetpack.json @@ -0,0 +1 @@ +{"v":"5.3.4","fr":30,"ip":0,"op":95,"w":310,"h":464,"nm":"Jetpack","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Jetpack Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.344],"y":[1]},"o":{"x":[0.655],"y":[0]},"n":["0p344_1_0p655_0"],"t":20,"s":[0],"e":[1800]},{"t":50}],"ix":10},"p":{"a":0,"k":[155,291,0],"ix":2},"a":{"a":0,"k":[68,68,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-14.126,-27.274],[-14.126,37.664],[19.293,-27.274]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[85.523,84.065],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[14.124,27.282],[14.124,-37.658],[-19.296,27.282]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[50.476,51.935],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[37.556,0],[0,-37.556],[-37.556,0],[0,37.556]],"o":[[-37.556,0],[0,37.556],[37.556,0],[0,-37.556]],"v":[[0,-68],[-68,0],[0,68],[68,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.8,0.807843137255,0.81568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[68,68],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"iPhone Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,305,0],"ix":2},"a":{"a":0,"k":[153,303,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,27],[135,27],[135,-27],[-135,-27]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.376470588235,0.529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,84],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,217.5],[135,217.5],[135,-217.5],[-135,-217.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,328.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-23.1],[0,0],[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0]],"o":[[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0],[0,0],[0,-23.1],[0,0]],"v":[[-111,-303],[-153,-261],[-153,261],[-111,303],[111,303],[153,261],[153,-261],[111,-303]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,303],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0}],"markers":[]} diff --git a/WordPressAuthenticator/Sources/Resources/Animations/notifications.json b/WordPressAuthenticator/Sources/Resources/Animations/notifications.json new file mode 100644 index 000000000000..bafe03becc1d --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Animations/notifications.json @@ -0,0 +1 @@ +{"v":"5.3.4","fr":30,"ip":0,"op":47,"w":310,"h":464,"nm":"Notifications","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"notification-01 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":7,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":14,"s":[100],"e":[100]},{"t":17}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,222.086,0],"ix":2},"a":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":7,"s":[115,73,0],"e":[115,83,0],"to":[0,1.66666662693024,0],"ti":[0,-1.66666662693024,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":14,"s":[115,83,0],"e":[115,83,0],"to":[0,0,0],"ti":[0,0,0]},{"t":17}],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":7,"s":[75,75,100],"e":[103,103,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"n":["0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167","0p833_0p833_0p167_0p167"],"t":14,"s":[103,103,100],"e":[100,100,100]},{"t":17}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.81,0.213],[3.849,-0.037],[3.849,-0.838],[-0.276,-1.284],[-0.879,-0.193],[-3.849,-0.064],[-3.849,1.004],[0.332,1.285]],"o":[[-3.849,-1.005],[-3.849,0.064],[-1.278,0.278],[0.203,0.942],[3.849,0.836],[3.849,0.038],[1.277,-0.334],[-0.225,-0.872]],"v":[[-54.525,-12.412],[-66.074,-13.557],[-77.622,-12.412],[-79.436,-9.584],[-77.622,-7.761],[-66.074,-6.617],[-54.525,-7.761],[-52.812,-10.691]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[1.162,0.067],[9.639,0.219],[9.64,-0.071],[9.64,-0.253],[9.639,-0.434],[-0.056,-1.284],[-1.181,-0.057],[-9.639,-0.168],[-9.639,-0.106],[-9.64,0.075],[-9.639,0.528],[0.07,1.285]],"o":[[-9.639,-0.53],[-9.64,-0.075],[-9.639,0.107],[-9.639,0.17],[-1.278,0.056],[0.055,1.211],[9.639,0.434],[9.64,0.256],[9.64,0.071],[9.639,-0.219],[1.279,-0.07],[-0.064,-1.194]],"v":[[77.454,-12.377],[48.536,-13.335],[19.616,-13.505],[-9.303,-13.218],[-38.223,-12.377],[-40.434,-9.949],[-38.223,-7.727],[-9.303,-6.889],[19.616,-6.6],[48.536,-6.77],[77.454,-7.727],[79.643,-10.18]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.81,0.212],[3.85,-0.037],[3.851,-0.838],[-0.276,-1.284],[-0.879,-0.193],[-3.849,-0.063],[-3.85,1.005],[0.333,1.284]],"o":[[-3.85,-1.005],[-3.849,0.063],[-1.277,0.278],[0.203,0.941],[3.851,0.837],[3.85,0.038],[1.278,-0.333],[-0.224,-0.872]],"v":[[53.143,7.742],[41.594,6.597],[30.045,7.742],[28.232,10.569],[30.045,12.392],[41.594,13.536],[53.143,12.392],[54.855,9.463]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[-0.097,-1.15],[1.277,-0.109],[6.212,-0.219],[6.212,0.074],[6.212,0.254],[6.212,0.434],[0.081,1.174],[-1.278,0.088],[-6.211,0.169],[-6.212,0.107],[-6.211,-0.151],[-6.212,-0.529]],"o":[[0.108,1.284],[-6.212,0.529],[-6.211,0.15],[-6.212,-0.107],[-6.211,-0.17],[-1.137,-0.078],[-0.089,-1.286],[6.212,-0.434],[6.212,-0.254],[6.212,-0.075],[6.212,0.219],[1.108,0.093]],"v":[[11.233,9.87],[9.116,12.392],[-9.521,13.349],[-28.156,13.52],[-46.791,13.232],[-65.426,12.392],[-67.579,10.229],[-65.426,7.742],[-46.791,6.902],[-28.156,6.614],[-9.521,6.785],[9.116,7.742]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.882352941176,0.886274509804,0.886274509804,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[113.584,60.612],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,5.545],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[5.525,0]],"v":[[39.855,3.918],[39.855,-13.956],[-39.855,-13.956],[-39.855,13.956],[29.851,13.956]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.376470588235,0.529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[113.857,108.956],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-95.5,54.501],[95.5,54.501],[95.5,-54.501],[-95.5,-54.501]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[114.5,80.501],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"iPhone Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,305,0],"ix":2},"a":{"a":0,"k":[153,303,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,27],[135,27],[135,-27],[-135,-27]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.376470588235,0.529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,84],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,217.5],[135,217.5],[135,-217.5],[-135,-217.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.964705882353,0.964705882353,0.964705882353,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,328.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-23.1],[0,0],[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0]],"o":[[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0],[0,0],[0,-23.1],[0,0]],"v":[[-111,-303],[-153,-261],[-153,261],[-111,303],[111,303],[153,261],[153,-261],[111,-303]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,303],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0}],"markers":[]} diff --git a/WordPressAuthenticator/Sources/Resources/Animations/post.json b/WordPressAuthenticator/Sources/Resources/Animations/post.json new file mode 100644 index 000000000000..974f82d2818b --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Animations/post.json @@ -0,0 +1 @@ +{"v":"5.3.4","fr":30,"ip":0,"op":135,"w":310,"h":464,"nm":"post-on-the-go","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 4 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":34,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":36,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":82,"s":[100],"e":[0]},{"t":84}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155.028,232.392,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.047,0.228],[-4.59,0.075],[-4.589,-1.191],[0.396,-1.524],[0.966,-0.253],[4.589,0.044],[4.59,0.992],[-0.329,1.524]],"o":[[4.59,-0.993],[4.589,-0.045],[1.524,0.395],[-0.267,1.034],[-4.589,1.191],[-4.59,-0.075],[-1.522,-0.329],[0.241,-1.116]],"v":[[-13.742,-2.757],[0.027,-4.115],[13.794,-2.757],[15.836,0.717],[13.794,2.758],[0.027,4.116],[-13.742,2.758],[-15.903,-0.596]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[169.994,185.684],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":22,"op":84,"st":22,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 5 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":28,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":30,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":84,"s":[100],"e":[0]},{"t":86}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155.028,232.392,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.047,0.228],[-4.589,0.075],[-4.589,-1.191],[0.395,-1.524],[0.965,-0.253],[4.59,0.044],[4.589,0.992],[-0.329,1.524]],"o":[[4.589,-0.993],[4.59,-0.045],[1.523,0.395],[-0.268,1.034],[-4.589,1.191],[-4.589,-0.075],[-1.523,-0.329],[0.242,-1.116]],"v":[[-13.741,-2.757],[0.027,-4.115],[13.795,-2.757],[15.837,0.717],[13.795,2.758],[0.027,4.116],[-13.741,2.758],[-15.903,-0.596]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[126.146,185.684],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":20,"op":86,"st":20,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Layer 6 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":25,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":27,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":87,"s":[100],"e":[0]},{"t":89}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155.028,232.392,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.357,0.093],[-7.415,0.2],[-7.415,0.127],[-7.415,-0.179],[-7.415,-0.628],[0.13,-1.524],[1.323,-0.111],[7.415,-0.26],[7.416,0.088],[7.415,0.301],[7.415,0.514],[-0.106,1.525]],"o":[[7.415,-0.515],[7.415,-0.303],[7.416,-0.089],[7.415,0.259],[1.525,0.129],[-0.115,1.367],[-7.415,0.628],[-7.415,0.179],[-7.415,-0.127],[-7.415,-0.202],[-1.525,-0.106],[0.097,-1.396]],"v":[[-44.611,-2.761],[-22.365,-3.757],[-0.12,-4.1],[22.126,-3.897],[44.372,-2.761],[46.899,0.234],[44.372,2.762],[22.126,3.898],[-0.12,4.101],[-22.365,3.759],[-44.611,2.762],[-47.181,-0.191]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[47.029,185.684],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":17,"op":89,"st":17,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Layer 3 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":16,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":18,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":93,"s":[100],"e":[0]},{"t":95}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155.028,232.392,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.302,0.067],[-10.625,0.201],[-10.625,0.127],[-10.625,-0.089],[-10.625,-0.629],[0.077,-1.525],[1.279,-0.081],[10.624,-0.259],[10.624,0.084],[10.624,0.302],[10.624,0.515],[-0.063,1.525]],"o":[[10.624,-0.515],[10.624,-0.302],[10.624,-0.084],[10.624,0.26],[1.408,0.083],[-0.071,1.419],[-10.625,0.628],[-10.625,0.09],[-10.625,-0.127],[-10.625,-0.201],[-1.409,-0.068],[0.06,-1.439]],"v":[[-63.704,-2.761],[-31.83,-3.758],[0.043,-4.1],[31.917,-3.898],[63.79,-2.761],[66.2,0.151],[63.79,2.761],[31.917,3.897],[0.043,4.1],[-31.83,3.758],[-63.704,2.761],[-66.14,-0.123]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[163.797,162.199],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":11,"op":95,"st":11,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Layer 7 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":9,"s":[0],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":11,"s":[100],"e":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"n":["0p833_0p833_0p167_0p167"],"t":95,"s":[100],"e":[0]},{"t":97}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155.028,232.392,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.33,0.107],[-6.293,0.2],[-6.293,0.127],[-6.294,-0.179],[-6.293,-0.628],[0.153,-1.525],[1.288,-0.126],[6.292,-0.26],[6.293,0.089],[6.293,0.301],[6.292,0.515],[-0.125,1.525]],"o":[[6.292,-0.515],[6.293,-0.303],[6.293,-0.089],[6.292,0.259],[1.525,0.152],[-0.133,1.338],[-6.293,0.629],[-6.294,0.179],[-6.293,-0.127],[-6.293,-0.202],[-1.525,-0.124],[0.112,-1.373]],"v":[[-37.884,-2.761],[-19.005,-3.757],[-0.126,-4.1],[18.754,-3.897],[37.633,-2.761],[40.118,0.276],[37.633,2.761],[18.754,3.898],[-0.126,4.1],[-19.005,3.759],[-37.884,2.761],[-40.42,-0.225]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[40.271,162.189],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":9,"op":97,"st":9,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"text-bodymovin Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,232.087,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.242,-1.118],[-1.525,-0.33],[-4.596,-0.076],[-4.594,1.193],[-0.268,1.037],[1.526,0.396],[4.595,-0.044],[4.595,-0.993]],"o":[[-0.33,1.524],[4.595,0.995],[4.595,0.044],[0.967,-0.254],[0.396,-1.525],[-4.594,-1.192],[-4.596,0.076],[-1.049,0.228]],"v":[[-114.981,28.062],[-112.816,31.42],[-99.03,32.78],[-85.245,31.42],[-83.2,29.375],[-85.245,25.897],[-99.03,24.538],[-112.816,25.897]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[0.06,-1.438],[-1.408,-0.069],[-10.625,-0.201],[-10.625,-0.126],[-10.625,0.09],[-10.625,0.628],[-0.071,1.419],[1.408,0.084],[10.624,0.259],[10.624,-0.085],[10.624,-0.302],[10.624,-0.514]],"o":[[-0.063,1.526],[10.624,0.514],[10.624,0.302],[10.624,0.085],[10.624,-0.259],[1.279,-0.081],[0.077,-1.526],[-10.625,-0.628],[-10.625,-0.09],[-10.625,0.127],[-10.625,0.201],[-1.302,0.068]],"v":[[-17.344,-28.749],[-14.908,-25.863],[16.966,-24.867],[48.839,-24.525],[80.713,-24.728],[112.586,-25.863],[114.996,-28.474],[112.586,-31.387],[80.713,-32.522],[48.839,-32.725],[16.966,-32.383],[-14.908,-31.387]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[0.112,-1.372],[-1.525,-0.125],[-6.293,-0.201],[-6.293,-0.127],[-6.294,0.179],[-6.293,0.628],[-0.134,1.338],[1.525,0.152],[6.292,0.26],[6.293,-0.088],[6.294,-0.302],[6.293,-0.515]],"o":[[-0.125,1.525],[6.293,0.515],[6.294,0.302],[6.293,0.088],[6.292,-0.26],[1.288,-0.128],[0.152,-1.525],[-6.293,-0.628],[-6.294,-0.179],[-6.293,0.127],[-6.293,0.201],[-1.329,0.107]],"v":[[-115.149,-28.861],[-112.613,-25.874],[-93.734,-24.878],[-74.855,-24.535],[-55.975,-24.738],[-37.096,-25.874],[-34.61,-28.36],[-37.096,-31.397],[-55.975,-32.533],[-74.855,-32.736],[-93.734,-32.393],[-112.613,-31.397]],"c":true},"ix":2},"nm":"Path 3","mn":"ADBE Vector Shape - Group","hd":false},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.065,-1.438],[-1.526,-0.067],[-11.508,-0.201],[-11.508,-0.127],[-11.508,0.089],[-11.508,0.629],[-0.077,1.419],[1.525,0.083],[11.507,0.259],[11.507,-0.084],[11.507,-0.303],[11.507,-0.515]],"o":[[-0.068,1.525],[11.507,0.515],[11.507,0.302],[11.507,0.084],[11.507,-0.26],[1.385,-0.08],[0.084,-1.525],[-11.508,-0.628],[-11.508,-0.09],[-11.508,0.127],[-11.508,0.2],[-1.41,0.067]],"v":[[-68.421,28.495],[-65.783,31.379],[-31.26,32.376],[3.262,32.718],[37.785,32.516],[72.308,31.379],[74.918,28.769],[72.308,25.857],[37.785,24.721],[3.262,24.518],[-31.26,24.861],[-65.783,25.857]],"c":true},"ix":2},"nm":"Path 4","mn":"ADBE Vector Shape - Group","hd":false},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.242,-1.117],[-1.525,-0.33],[-4.596,-0.075],[-4.596,1.192],[-0.268,1.036],[1.525,0.396],[4.596,-0.044],[4.595,-0.994]],"o":[[-0.329,1.526],[4.595,0.994],[4.596,0.044],[0.967,-0.253],[0.396,-1.525],[-4.596,-1.193],[-4.596,0.075],[-1.049,0.229]],"v":[[-0.778,-0.621],[1.387,2.738],[15.173,4.097],[28.959,2.738],[31.003,0.693],[28.959,-2.785],[15.173,-4.144],[1.387,-2.785]],"c":true},"ix":2},"nm":"Path 5","mn":"ADBE Vector Shape - Group","hd":false},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[-1.357,0.093],[-7.415,0.201],[-7.415,0.127],[-7.415,-0.179],[-7.415,-0.629],[0.129,-1.525],[1.323,-0.111],[7.415,-0.259],[7.415,0.089],[7.416,0.303],[7.415,0.514],[-0.106,1.525]],"o":[[7.415,-0.514],[7.416,-0.302],[7.415,-0.088],[7.415,0.259],[1.526,0.129],[-0.116,1.367],[-7.415,0.628],[-7.415,0.179],[-7.415,-0.127],[-7.415,-0.2],[-1.525,-0.106],[0.096,-1.396]],"v":[[-112.582,-2.785],[-90.336,-3.782],[-68.09,-4.124],[-45.844,-3.921],[-23.599,-2.785],[-21.071,0.21],[-23.599,2.738],[-45.844,3.874],[-68.09,4.077],[-90.336,3.734],[-112.582,2.738],[-115.151,-0.215]],"c":true},"ix":2},"nm":"Path 6","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847058823529,0.870588235294,0.894117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115,104.848],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":7,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"text-bodymovin Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,232.087,0],"ix":2},"a":{"a":0,"k":[115,100,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[12.516,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,-12.516],[0,0],[0,0],[0,0]],"v":[[-96.144,18.343],[115.035,18.343],[115.035,4.32],[92.373,-18.343],[-115,-18.343],[-115,18.343]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.435294117647,0.576470588235,0.678431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115,18.343],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":8,"ty":4,"nm":"Image Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":10,"s":[155,374,0],"e":[155,402,0],"to":[0,4.66666650772095,0],"ti":[0,-4.66666650772095,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":12,"s":[155,402,0],"e":[155,402,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":25,"s":[155,402,0],"e":[155,430,0],"to":[0,4.66666650772095,0],"ti":[0,-4.66666650772095,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":27,"s":[155,430,0],"e":[155,430,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":87,"s":[155,430,0],"e":[155,402,0],"to":[0,-4.66666650772095,0],"ti":[0,4.66666650772095,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":89,"s":[155,402,0],"e":[155,402,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"n":"0p833_0p833_0p167_0p167","t":94,"s":[155,402,0],"e":[155,374,0],"to":[0,-4.66666650772095,0],"ti":[0,4.66666650772095,0]},{"t":96}],"ix":2},"a":{"a":0,"k":[115,70,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-12.15],[12.15,0],[0,12.15],[-12.15,0]],"o":[[0,12.15],[-12.15,0],[0,-12.15],[12.15,0]],"v":[[22,0],[0,22],[-22,0],[0,-22]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[191.039,37.805],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.83],[0,0],[26.664,3.881],[44.378,51.841],[0,0],[0,0]],"o":[[0,0],[-11.598,-14.112],[-48.045,-6.995],[0,0],[0,0],[1.83,0]],"v":[[115,46.772],[115,19.79],[56.449,-6.075],[-115,-50.085],[-115,50.085],[111.687,50.085]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.435294117647,0.576470588235,0.678431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115,89.771],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.83,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,1.83]],"v":[[111.687,69.855],[-115,69.855],[-115,-69.855],[115,-69.855],[115,66.542]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.733333333333,0.788235294118,0.835294117647,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115,70.001],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"iPhone Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,305,0],"ix":2},"a":{"a":0,"k":[153,303,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,27],[135,27],[135,-27],[-135,-27]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.376470588235,0.529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,84],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,217.5],[135,217.5],[135,-217.5],[-135,-217.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,328.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-23.1],[0,0],[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0]],"o":[[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0],[0,0],[0,-23.1],[0,0]],"v":[[-111,-303],[-153,-261],[-153,261],[-111,303],[111,303],[153,261],[153,-261],[111,-303]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153.328,303.21],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0}],"markers":[]} diff --git a/WordPressAuthenticator/Sources/Resources/Animations/reader.json b/WordPressAuthenticator/Sources/Resources/Animations/reader.json new file mode 100644 index 000000000000..0d9cd7a9b3b7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Animations/reader.json @@ -0,0 +1 @@ +{"v":"4.7.0","fr":30,"ip":0,"op":150,"w":306,"h":462,"nm":"Reader","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Mask Outlines","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[152.724,28.147,0]},"a":{"a":0,"k":[135,217.5,0]},"s":{"a":0,"k":[84.074,13.103,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[185.879,-169.017]],"c":false}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"st","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":2},"lc":1,"lj":1,"ml":4,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke"},{"ty":"fl","c":{"a":0,"k":[0.1372549,0.2078431,0.2941176,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-134.937,27.126],[135.188,27.06],[135.25,-27.016],[-135,-27]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.0039216,0.3764706,0.5294118,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[135.174,643.654],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[118.777,759.676],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,0]],"v":[[-141.244,223.056],[146.374,223.056],[136.711,-211.481],[-136.783,-210.554],[-140.45,-160.256]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.1372549,0.2078431,0.2941177,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[135,217.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100.332,103.011],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group"}],"ip":0,"op":419,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":2,"ty":4,"nm":"Cards Outlines","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"s":true,"x":{"a":1,"k":[{"i":{"x":[0.967],"y":[1]},"o":{"x":[0.158],"y":[0]},"n":["0p967_1_0p158_0"],"t":10,"s":[153],"e":[153]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.205],"y":[0]},"n":["0p833_1_0p205_0"],"t":46,"s":[153],"e":[153]},{"i":{"x":[0.985],"y":[1]},"o":{"x":[0.015],"y":[0]},"n":["0p985_1_0p015_0"],"t":90,"s":[153],"e":[153]},{"t":110}]},"y":{"a":1,"k":[{"i":{"x":[1],"y":[1]},"o":{"x":[0.167],"y":[0]},"n":["1_1_0p167_0"],"t":0,"s":[431],"e":[432]},{"i":{"x":[0.069],"y":[1]},"o":{"x":[0.216],"y":[0]},"n":["0p069_1_0p216_0"],"t":10,"s":[432],"e":[98]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.205],"y":[0.185]},"n":["0p833_1_0p205_0p185"],"t":44,"s":[98],"e":[98]},{"i":{"x":[0],"y":[1]},"o":{"x":[0.301],"y":[0]},"n":["0_1_0p301_0"],"t":90,"s":[98],"e":[432]},{"t":118}]}},"a":{"a":0,"k":[115,300,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.047,-0.229],[-4.59,-0.075],[-4.589,1.191],[0.396,1.524],[0.966,0.253],[4.589,-0.044],[4.59,-0.992],[-0.329,-1.524]],"o":[[4.59,0.992],[4.589,0.044],[1.524,-0.396],[-0.267,-1.035],[-4.589,-1.191],[-4.59,0.075],[-1.522,0.329],[0.241,1.115]],"v":[[41.252,-80.641],[55.021,-79.284],[68.788,-80.641],[70.83,-84.115],[68.788,-86.157],[55.021,-87.514],[41.252,-86.157],[39.091,-82.802]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-1.048,-0.229],[-4.589,-0.075],[-4.588,1.191],[0.396,1.524],[0.966,0.253],[4.59,-0.044],[4.589,-0.992],[-0.329,-1.524]],"o":[[4.589,0.992],[4.59,0.044],[1.524,-0.396],[-0.267,-1.035],[-4.588,-1.191],[-4.589,0.075],[-1.523,0.329],[0.241,1.115]],"v":[[-2.595,-80.641],[11.173,-79.284],[24.94,-80.641],[26.982,-84.115],[24.94,-86.157],[11.173,-87.514],[-2.595,-86.157],[-4.757,-82.802]],"c":true}},"nm":"Path 2","mn":"ADBE Vector Shape - Group"},{"ind":2,"ty":"sh","ix":3,"ks":{"a":0,"k":{"i":[[-1.358,-0.093],[-7.415,-0.201],[-7.415,-0.127],[-7.415,0.178],[-7.415,0.628],[0.129,1.525],[1.323,0.112],[7.415,0.259],[7.415,-0.088],[7.416,-0.301],[7.415,-0.514],[-0.106,-1.526]],"o":[[7.415,0.515],[7.416,0.302],[7.415,0.088],[7.415,-0.26],[1.526,-0.129],[-0.116,-1.366],[-7.415,-0.627],[-7.415,-0.179],[-7.415,0.127],[-7.415,0.202],[-1.526,0.107],[0.096,1.395]],"v":[[-112.581,-80.637],[-90.336,-79.641],[-68.09,-79.298],[-45.844,-79.501],[-23.599,-80.637],[-21.071,-83.633],[-23.599,-86.161],[-45.844,-87.296],[-68.09,-87.499],[-90.336,-87.157],[-112.581,-86.161],[-115.151,-83.207]],"c":true}},"nm":"Path 3","mn":"ADBE Vector Shape - Group"},{"ind":3,"ty":"sh","ix":4,"ks":{"a":0,"k":{"i":[[0.241,-1.116],[-1.522,-0.329],[-4.59,-0.076],[-4.589,1.191],[-0.267,1.035],[1.524,0.396],[4.589,-0.044],[4.59,-0.992]],"o":[[-0.329,1.523],[4.59,0.993],[4.589,0.044],[0.966,-0.253],[0.396,-1.523],[-4.589,-1.191],[-4.59,0.075],[-1.047,0.229]],"v":[[39.091,177.251],[41.252,180.605],[55.021,181.963],[68.788,180.605],[70.83,178.563],[68.788,175.089],[55.021,173.732],[41.252,175.089]],"c":true}},"nm":"Path 4","mn":"ADBE Vector Shape - Group"},{"ind":4,"ty":"sh","ix":5,"ks":{"a":0,"k":{"i":[[0.241,-1.116],[-1.523,-0.329],[-4.589,-0.076],[-4.588,1.191],[-0.267,1.035],[1.524,0.396],[4.59,-0.044],[4.589,-0.992]],"o":[[-0.329,1.523],[4.589,0.993],[4.59,0.044],[0.966,-0.253],[0.396,-1.523],[-4.588,-1.191],[-4.589,0.075],[-1.048,0.229]],"v":[[-4.757,177.251],[-2.595,180.605],[11.173,181.963],[24.94,180.605],[26.982,178.563],[24.94,175.089],[11.173,173.732],[-2.595,175.089]],"c":true}},"nm":"Path 5","mn":"ADBE Vector Shape - Group"},{"ind":5,"ty":"sh","ix":6,"ks":{"a":0,"k":{"i":[[0.096,-1.396],[-1.526,-0.105],[-7.415,-0.2],[-7.415,-0.127],[-7.415,0.178],[-7.415,0.628],[-0.116,1.366],[1.526,0.129],[7.415,0.26],[7.415,-0.088],[7.416,-0.301],[7.415,-0.515]],"o":[[-0.106,1.525],[7.415,0.515],[7.416,0.303],[7.415,0.089],[7.415,-0.259],[1.323,-0.111],[0.129,-1.526],[-7.415,-0.628],[-7.415,-0.179],[-7.415,0.127],[-7.415,0.202],[-1.358,0.092]],"v":[[-115.151,177.656],[-112.581,180.609],[-90.336,181.605],[-68.09,181.948],[-45.844,181.745],[-23.599,180.609],[-21.071,178.082],[-23.599,175.086],[-45.844,173.95],[-68.09,173.747],[-90.336,174.089],[-112.581,175.086]],"c":true}},"nm":"Path 6","mn":"ADBE Vector Shape - Group"},{"ind":6,"ty":"sh","ix":7,"ks":{"a":0,"k":{"i":[[0.06,-1.438],[-1.408,-0.069],[-10.625,-0.201],[-10.625,-0.126],[-10.625,0.09],[-10.625,0.628],[-0.071,1.419],[1.408,0.083],[10.624,0.259],[10.624,-0.084],[10.624,-0.303],[10.624,-0.514]],"o":[[-0.063,1.525],[10.624,0.513],[10.624,0.301],[10.624,0.084],[10.624,-0.259],[1.279,-0.081],[0.077,-1.525],[-10.625,-0.628],[-10.625,-0.089],[-10.625,0.127],[-10.625,0.2],[-1.302,0.068]],"v":[[-17.344,154.239],[-14.908,157.125],[16.966,158.121],[48.839,158.463],[80.713,158.26],[112.586,157.125],[114.996,154.513],[112.586,151.601],[80.713,150.465],[48.839,150.262],[16.966,150.605],[-14.908,151.601]],"c":true}},"nm":"Path 7","mn":"ADBE Vector Shape - Group"},{"ind":7,"ty":"sh","ix":8,"ks":{"a":0,"k":{"i":[[0.112,-1.372],[-1.525,-0.125],[-6.293,-0.201],[-6.292,-0.127],[-6.294,0.178],[-6.293,0.628],[-0.134,1.337],[1.525,0.153],[6.292,0.259],[6.294,-0.088],[6.294,-0.301],[6.293,-0.514]],"o":[[-0.125,1.525],[6.293,0.515],[6.294,0.303],[6.294,0.088],[6.292,-0.26],[1.288,-0.128],[0.153,-1.526],[-6.293,-0.627],[-6.294,-0.179],[-6.292,0.127],[-6.293,0.202],[-1.329,0.108]],"v":[[-115.149,154.127],[-112.613,157.114],[-93.734,158.11],[-74.855,158.453],[-55.975,158.25],[-37.096,157.114],[-34.61,154.628],[-37.096,151.59],[-55.975,150.455],[-74.855,150.252],[-93.734,150.594],[-112.613,151.59]],"c":true}},"nm":"Path 8","mn":"ADBE Vector Shape - Group"},{"ind":8,"ty":"sh","ix":9,"ks":{"a":0,"k":{"i":[[0.242,-1.117],[-1.525,-0.329],[-4.595,-0.075],[-4.594,1.193],[-0.268,1.036],[1.526,0.396],[4.595,-0.045],[4.595,-0.994]],"o":[[-0.33,1.526],[4.595,0.994],[4.595,0.045],[0.967,-0.253],[0.396,-1.525],[-4.594,-1.193],[-4.595,0.075],[-1.049,0.229]],"v":[[-114.981,-121.151],[-112.816,-117.792],[-99.03,-116.433],[-85.245,-117.792],[-83.2,-119.837],[-85.245,-123.315],[-99.03,-124.674],[-112.816,-123.315]],"c":true}},"nm":"Path 9","mn":"ADBE Vector Shape - Group"},{"ind":9,"ty":"sh","ix":10,"ks":{"a":0,"k":{"i":[[0.06,-1.438],[-1.408,-0.068],[-10.625,-0.201],[-10.625,-0.127],[-10.625,0.09],[-10.625,0.628],[-0.071,1.419],[1.408,0.083],[10.624,0.26],[10.624,-0.085],[10.624,-0.302],[10.624,-0.514]],"o":[[-0.063,1.526],[10.624,0.515],[10.624,0.302],[10.624,0.084],[10.624,-0.259],[1.279,-0.081],[0.077,-1.526],[-10.625,-0.628],[-10.625,-0.09],[-10.625,0.127],[-10.625,0.201],[-1.302,0.068]],"v":[[-17.344,-177.961],[-14.908,-175.076],[16.966,-174.079],[48.839,-173.737],[80.713,-173.94],[112.586,-175.076],[114.996,-177.686],[112.586,-180.599],[80.713,-181.735],[48.839,-181.937],[16.966,-181.595],[-14.908,-180.599]],"c":true}},"nm":"Path 10","mn":"ADBE Vector Shape - Group"},{"ind":10,"ty":"sh","ix":11,"ks":{"a":0,"k":{"i":[[0.112,-1.372],[-1.525,-0.125],[-6.293,-0.201],[-6.292,-0.127],[-6.294,0.179],[-6.293,0.628],[-0.134,1.338],[1.525,0.152],[6.292,0.259],[6.294,-0.089],[6.294,-0.303],[6.293,-0.515]],"o":[[-0.125,1.526],[6.293,0.514],[6.294,0.302],[6.294,0.088],[6.292,-0.26],[1.288,-0.128],[0.153,-1.526],[-6.293,-0.628],[-6.294,-0.179],[-6.292,0.127],[-6.293,0.2],[-1.329,0.107]],"v":[[-115.149,-178.074],[-112.613,-175.086],[-93.734,-174.09],[-74.855,-173.747],[-55.975,-173.95],[-37.096,-175.086],[-34.61,-177.572],[-37.096,-180.609],[-55.975,-181.745],[-74.855,-181.948],[-93.734,-181.605],[-112.613,-180.609]],"c":true}},"nm":"Path 11","mn":"ADBE Vector Shape - Group"},{"ind":11,"ty":"sh","ix":12,"ks":{"a":0,"k":{"i":[[0.065,-1.439],[-1.526,-0.069],[-11.508,-0.201],[-11.508,-0.126],[-11.508,0.09],[-11.508,0.628],[-0.077,1.418],[1.525,0.084],[11.507,0.26],[11.508,-0.084],[11.507,-0.302],[11.507,-0.514]],"o":[[-0.068,1.525],[11.507,0.514],[11.507,0.302],[11.508,0.084],[11.507,-0.258],[1.385,-0.081],[0.084,-1.525],[-11.508,-0.628],[-11.508,-0.089],[-11.508,0.127],[-11.508,0.201],[-1.41,0.068]],"v":[[-68.421,-120.717],[-65.783,-117.832],[-31.26,-116.836],[3.262,-116.494],[37.785,-116.697],[72.308,-117.832],[74.918,-120.443],[72.308,-123.356],[37.785,-124.492],[3.262,-124.695],[-31.26,-124.352],[-65.783,-123.356]],"c":true}},"nm":"Path 12","mn":"ADBE Vector Shape - Group"},{"ind":12,"ty":"sh","ix":13,"ks":{"a":0,"k":{"i":[[0.242,-1.118],[-1.525,-0.33],[-4.596,-0.075],[-4.596,1.192],[-0.268,1.036],[1.525,0.395],[4.596,-0.044],[4.595,-0.995]],"o":[[-0.329,1.525],[4.595,0.993],[4.596,0.045],[0.967,-0.254],[0.396,-1.525],[-4.596,-1.193],[-4.596,0.076],[-1.049,0.228]],"v":[[-0.778,-149.833],[1.387,-146.474],[15.173,-145.116],[28.959,-146.474],[31.003,-148.519],[28.959,-151.997],[15.173,-153.357],[1.387,-151.997]],"c":true}},"nm":"Path 13","mn":"ADBE Vector Shape - Group"},{"ind":13,"ty":"sh","ix":14,"ks":{"a":0,"k":{"i":[[-1.358,0.092],[-7.415,0.201],[-7.415,0.127],[-7.415,-0.179],[-7.415,-0.628],[0.129,-1.525],[1.323,-0.111],[7.415,-0.26],[7.415,0.088],[7.416,0.302],[7.415,0.515],[-0.106,1.524]],"o":[[7.415,-0.515],[7.416,-0.302],[7.415,-0.089],[7.415,0.259],[1.526,0.128],[-0.116,1.367],[-7.415,0.628],[-7.415,0.178],[-7.415,-0.127],[-7.415,-0.201],[-1.526,-0.106],[0.096,-1.396]],"v":[[-112.581,-151.997],[-90.336,-152.994],[-68.09,-153.336],[-45.844,-153.133],[-23.599,-151.997],[-21.071,-149.002],[-23.599,-146.474],[-45.844,-145.338],[-68.09,-145.135],[-90.336,-145.478],[-112.581,-146.474],[-115.151,-149.427]],"c":true}},"nm":"Path 14","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.8470588,0.8705882,0.8941176,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[115,354.135],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":15,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.464,-1.615],[-2.528,5.622]],"o":[[-3.106,3.428],[1.811,-4.028]],"v":[[-1.597,-3.845],[2.892,-0.161]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.9529412,0.9607843,0.9647059,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[126.659,443.744],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.818,2.02],[3.963,-4.719]],"o":[[1.736,-4.288],[-2.84,3.382]],"v":[[-4.207,-0.767],[1.062,1.673]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.9529412,0.9607843,0.9647059,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[201.302,439.586],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.817,2.02],[2.556,-3.602]],"o":[[1.737,-4.288],[-2.555,3.602]],"v":[[-2.993,-1.016],[1.254,1.702]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.9529412,0.9607843,0.9647059,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[177.04,420.634],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 4","np":2,"cix":2,"ix":4,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[2.178,-0.091],[-4.251,1.194]],"o":[[-4.622,0.192],[4.252,-1.194]],"v":[[-0.38,-3.009],[0.751,1.905]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.9529412,0.9607843,0.9647059,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[198.404,379.927],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 5","np":2,"cix":2,"ix":5,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.456,-1.241],[-4.251,1.194]],"o":[[-4.456,1.241],[4.252,-1.194]],"v":[[-0.463,-2.434],[0.668,2.48]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.9529412,0.9607843,0.9647059,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[154.751,386.841],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 6","np":2,"cix":2,"ix":6,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.397,-1.477],[-0.458,-0.982],[-0.423,-0.219],[-0.108,-0.053],[-0.159,-0.008],[-0.338,0.02],[-1.422,0.465],[-2.715,1.657],[-2.597,1.948],[-4.737,4.567],[-4.492,4.831],[-4.051,5.145],[-2.962,5.657],[-0.774,2.887],[-0.142,0.714],[-0.036,0.346],[0,0],[0,0],[0.596,0.653],[1.319,0.095],[0.711,-0.06],[0.74,-0.225],[2.825,-1.639],[0,0],[0.339,0.585],[-0.445,0.375],[-3.349,1.406],[-2.038,0.076],[-0.261,-0.012],[0,0],[-0.268,-0.043],[0,0],[-0.267,-0.12],[0,0],[-0.462,-0.401],[-0.379,-0.473],[-0.265,-0.522],[-0.075,-1.012],[0.365,-1.807],[1.474,-3.168],[4.036,-5.49],[4.542,-5.015],[4.979,-4.615],[5.628,-3.936],[3.109,-1.6],[1.665,-0.631],[0.844,-0.289],[0.913,-0.167],[2.069,0.423],[0.509,0.181],[0.25,0.137],[0.223,0.216],[0.332,1.105],[-0.788,1.626],[-1.188,1.25],[-0.246,-0.233],[0.132,-0.239]],"o":[[-0.795,1.435],[-0.403,1.458],[0.264,0.452],[0.081,0.076],[0.129,0.035],[0.297,0.03],[1.236,0.071],[2.852,-0.899],[2.757,-1.605],[5.206,-3.894],[4.799,-4.498],[4.516,-4.81],[4.032,-5.149],[1.473,-2.818],[0.229,-0.729],[0.063,-0.355],[0,0],[0,0],[-0.028,-1.302],[-0.571,-0.67],[-0.644,-0.077],[-0.722,0.14],[-2.974,0.781],[0,0],[-0.584,0.338],[-0.302,-0.52],[2.62,-2.218],[1.671,-0.705],[0.255,-0.007],[0,0],[0.267,0.03],[0,0],[0.272,0.088],[0,0],[0.544,0.251],[0.496,0.378],[0.367,0.485],[0.404,1.042],[0.202,2.043],[-0.705,3.637],[-3.008,6.314],[-4.055,5.482],[-4.58,4.981],[-5.027,4.557],[-2.822,1.957],[-1.561,0.785],[-0.811,0.344],[-0.874,0.25],[-1.813,0.335],[-0.504,-0.07],[-0.256,-0.078],[-0.247,-0.163],[-0.96,-0.749],[-0.497,-2.26],[0.819,-1.639],[0.233,-0.245],[0.207,0.197],[0,0]],"v":[[-38.758,32.822],[-40.631,37.209],[-40.629,41.168],[-39.655,42.181],[-39.309,42.323],[-38.941,42.447],[-38.075,42.566],[-33.98,41.953],[-25.566,37.936],[-17.577,32.487],[-2.702,19.679],[11.171,5.569],[24.045,-9.373],[34.8,-25.553],[38.298,-34.177],[38.743,-36.321],[38.834,-37.353],[38.876,-37.867],[38.848,-38.345],[37.918,-41.382],[35.012,-42.575],[32.942,-42.502],[30.75,-42.031],[22.038,-38.12],[22.031,-38.116],[20.359,-38.561],[20.627,-40.108],[29.515,-45.652],[35.053,-46.937],[35.818,-46.957],[36.617,-46.881],[37.418,-46.784],[38.238,-46.552],[39.051,-46.281],[39.845,-45.866],[41.305,-44.798],[42.474,-43.421],[43.288,-41.882],[44.039,-38.76],[43.681,-32.991],[40.156,-22.894],[29.27,-5.393],[16.249,10.255],[1.924,24.636],[-13.991,37.469],[-22.864,42.84],[-27.696,44.975],[-30.238,45.846],[-32.911,46.478],[-38.747,46.546],[-40.302,46.046],[-41.065,45.639],[-41.794,45.148],[-43.744,42.192],[-42.832,36.36],[-39.729,32.085],[-38.862,32.063],[-38.748,32.804]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.0039216,0.3764706,0.5294118,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[76.621,402.73],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 7","np":2,"cix":2,"ix":7,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.953,-21.002],[21.895,7.258],[-2.144,21.472],[-21.188,-4.084]],"o":[[-4.953,21.002],[-20.482,-6.789],[2.407,-24.099],[22.139,4.268]],"v":[[36.814,8.758],[-9.791,35.815],[-39.622,-7.907],[6.122,-38.989]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[77.475,404.954],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 8","np":2,"cix":2,"ix":8,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[115,69.859],[-115.538,69.859],[-115.538,-69.859],[115,-69.859]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.7333333,0.7882353,0.8352941,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[115,403.141],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 9","np":2,"cix":2,"ix":9,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,-12.15],[12.15,0],[0,12.15],[-12.15,0]],"o":[[0,12.15],[-12.15,0],[0,-12.15],[12.15,0]],"v":[[22,0],[0,22],[-22,0],[0,-22]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[191.039,37.805],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 10","np":2,"cix":2,"ix":10,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,1.83],[0,0],[26.664,3.882],[44.378,51.842],[0,0],[0,0]],"o":[[0,0],[-11.598,-14.111],[-48.045,-6.994],[0,0],[0,0],[1.83,0]],"v":[[115,46.771],[115,19.788],[56.449,-6.074],[-115,-50.084],[-115,50.084],[111.687,50.084]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.4352941,0.5764706,0.6784314,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[115,89.771],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 11","np":2,"cix":2,"ix":11,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.83,0],[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[0,1.83]],"v":[[111.687,69.855],[-115,69.855],[-115,-69.855],[115,-69.855],[115,66.542]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.7333333,0.7882353,0.8352941,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[115,70],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 12","np":2,"cix":2,"ix":12,"mn":"ADBE Vector Group"}],"ip":0,"op":419,"st":0,"bm":0,"sr":1},{"ddd":0,"ind":3,"ty":4,"nm":"iPhone Outlines","ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[153,303,0]},"a":{"a":0,"k":[153,303,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,27],[135,27],[135,-27],[-135,-27]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.0039216,0.3764706,0.5294118,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[153,84],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,217.5],[135,217.5],[135,-217.5],[-135,-217.5]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[1,1,1,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[153,328.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group"},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-23.1],[0,0],[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0]],"o":[[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0],[0,0],[0,-23.1],[0,0]],"v":[[-111,-303],[-153,-261],[-153,261],[-111,303],[111,303],[153,261],[153,-261],[111,-303]],"c":true}},"nm":"Path 1","mn":"ADBE Vector Shape - Group"},{"ty":"fl","c":{"a":0,"k":[0.1372549,0.2078431,0.2941176,1]},"o":{"a":0,"k":100},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill"},{"ty":"tr","p":{"a":0,"k":[153.134,303.104],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group"}],"ip":0,"op":419,"st":0,"bm":0,"sr":1}]} diff --git a/WordPressAuthenticator/Sources/Resources/Animations/stats.json b/WordPressAuthenticator/Sources/Resources/Animations/stats.json new file mode 100644 index 000000000000..35557355eebc --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Animations/stats.json @@ -0,0 +1 @@ +{"v":"5.3.4","fr":30,"ip":0,"op":67,"w":310,"h":464,"nm":"Stats","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"stats-01 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,274.891,0],"ix":2},"a":{"a":0,"k":[115,117,0],"ix":1},"s":{"a":0,"k":[144.195,144.195,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.161,0.067],[9.64,0.219],[9.64,-0.071],[9.64,-0.254],[9.639,-0.434],[-0.057,-1.284],[-1.181,-0.056],[-9.64,-0.169],[-9.64,-0.107],[-9.64,0.076],[-9.64,0.529],[0.07,1.284]],"o":[[-9.64,-0.53],[-9.64,-0.075],[-9.64,0.106],[-9.64,0.17],[-1.279,0.056],[0.055,1.211],[9.639,0.434],[9.64,0.255],[9.64,0.071],[9.64,-0.218],[1.278,-0.07],[-0.065,-1.195]],"v":[[57.921,-14.887],[29.002,-15.845],[0.082,-16.014],[-28.837,-15.727],[-57.756,-14.887],[-59.967,-12.458],[-57.756,-10.237],[-28.837,-9.398],[0.082,-9.109],[29.002,-9.28],[57.921,-10.237],[60.109,-12.689]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-0.071,-1.15],[0.94,-0.109],[4.57,-0.218],[4.571,0.075],[4.571,0.254],[4.571,0.433],[0.06,1.175],[-0.94,0.089],[-4.57,0.169],[-4.571,0.107],[-4.571,-0.152],[-4.57,-0.529]],"o":[[0.08,1.284],[-4.57,0.529],[-4.571,0.15],[-4.571,-0.107],[-4.57,-0.17],[-0.836,-0.079],[-0.065,-1.285],[4.571,-0.433],[4.571,-0.255],[4.571,-0.075],[4.57,0.218],[0.815,0.093]],"v":[[-2.124,12.36],[-3.682,14.883],[-17.394,15.839],[-31.106,16.01],[-44.818,15.722],[-58.53,14.883],[-60.114,12.719],[-58.53,10.232],[-44.818,9.393],[-31.106,9.105],[-17.394,9.276],[-3.682,10.232]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.8,0.807843137255,0.81568627451,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[112.118,59.122],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":3,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,22.126],[15.628,22.126],[15.628,-22.126],[-15.628,-22.126]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[162.101,209.788],"ix":2},"a":{"a":0,"k":[0,22],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":21,"s":[100,0],"e":[100,100]},{"t":35}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 3-2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,51.566],[15.628,51.566],[15.628,-51.567],[-15.628,-51.567]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.274509803922,0.474509803922,0.603921568627,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[162.101,209.9],"ix":2},"a":{"a":0,"k":[0,51.5],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":16,"s":[100,0],"e":[100,100]},{"t":30}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 3-1","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,14.665],[15.628,14.665],[15.628,-14.665],[-15.628,-14.665]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115.079,209.776],"ix":2},"a":{"a":0,"k":[0,14.5],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":14,"s":[100,0],"e":[100,100]},{"t":29}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 2-2","np":2,"cix":2,"ix":4,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,34.178],[15.628,34.178],[15.628,-34.178],[-15.628,-34.178]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.274509803922,0.474509803922,0.603921568627,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[115,209.736],"ix":2},"a":{"a":0,"k":[0,34],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":9,"s":[100,0],"e":[100,100]},{"t":20}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 2-1","np":2,"cix":2,"ix":5,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,9.493],[15.628,9.493],[15.628,-9.493],[-15.628,-9.493]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[67.823,210.07],"ix":2},"a":{"a":0,"k":[0,9.5],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":8,"s":[100,0],"e":[100,100]},{"t":19}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 1-2","np":2,"cix":2,"ix":6,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-15.628,22.126],[15.628,22.126],[15.628,-22.126],[-15.628,-22.126]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.274509803922,0.474509803922,0.603921568627,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[67.899,209.788],"ix":2},"a":{"a":0,"k":[0,22],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"n":["0p667_1_0p333_0","0p667_1_0p333_0"],"t":4,"s":[100,0],"e":[100,100]},{"t":13}],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"bar 1-1","np":2,"cix":2,"ix":7,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"iPhone Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[155,305,0],"ix":2},"a":{"a":0,"k":[153,303,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,27],[135,27],[135,-27],[-135,-27]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.003921568627,0.376470588235,0.529411764706,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,84],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]],"v":[[-135,217.5],[135,217.5],[135,-217.5],[-135,-217.5]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153,328.5],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":2,"cix":2,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,-23.1],[0,0],[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0]],"o":[[-23.1,0],[0,0],[0,23.1],[0,0],[23.1,0],[0,0],[0,-23.1],[0,0]],"v":[[-111,-303],[-153,-261],[-153,261],[-111,303],[111,303],[153,261],[153,-261],[111,-303]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.137254901961,0.207843137255,0.294117647059,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[153.242,303.477],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":2,"cix":2,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":419,"st":0,"bm":0}],"markers":[]} diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/Contents.json new file mode 100644 index 000000000000..73c00596a7fc --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/Contents.json new file mode 100644 index 000000000000..92b10d87b4f7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "darkgrey-shadow.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "darkgrey-shadow@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "darkgrey-shadow@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow.png b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..e3d1026dcccf427b93e518f560923d87cd570a3b GIT binary patch literal 830 zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=`2Jfl)d$B%&n3*T*V3KUXg?B|j-u zuOhbqD9^xPV_#8_n4FzjqL7rDo|$K>^nUk#C56lsTcvPQUjyF)=hTc$kE){7;3~h6 zMhJ1(0FE1&_nsU?XD6}dTi#a0!zN?>!XfNYSkzLEl1NlCV?QiN}Sf^&XR zs)DJWsh)w79hZVlQA(Oskc%5sGmvMilu=SrV5P5LUS6(OZmgGIl&)`RX=$l%V5Dzk zqzhD`TU?n}l31aeSF8*&0%C?sYH@N=WU*oHAIkz?GXbd+9~yC1#FZAqt9V7qSn?}@z&2)Zcx zFgUSaODP#v@jiajex6Rz72<2y>U(4Anuig*+M2q`I{L@wdWuguReNT-#iN@$ zBRg%A?!IW5d8EHPQ;p+6@`S*Tc6)d(>TADx!vE*l`3aL_T=#eh_J&Sca^kVzR)wpN zg@hL!PhF-t>+k>D;kJf0A?x0KaDF^FanIMLTJC+`Z67N$B<{N>RJU~JnA(>!D8FAc zebuaL`8m=BWsz5ttPA#W#`0%{bpP`*Gt5?ET(5p%7sK1@7xsj_i%gyWxVmG_>G0Tt z@8rImtbG27U(Y-J%;vjqE&>xKXMsm#F#`j)FbFd;%$g$sO0AwQjv*3LlM^J|8UoL& z&XA1lX>5FGZ*}(2fddB|dpdP^czE6f#PS3POHBA-VCa_Qqmp5IrWPp5!^5z7EBmjG SEpz{ZlD(&^pUXO@geCwlW~a zDsl^e@(c_%_7w$*$=RtT3Q4KynR&KK?|1K4QpilPRSGxtHSjHPPR+>ls47YguJQ{> zuF6ifOi{A8KQ26aVgjorKDK}xwt_!19`Se86_nJR{Hwo<>h+i#(Mch>H3D2mX`VkM*2oZ zx=P7{9OiaozEwNQn(g#_h548p8Tz$BE zfgHGxQ}ck{ECTvR%4_v2U@$diIy(mx2e~^bc)B{98Csf|=^E)7GB9XNES-4T+sRR+ z-F~aVDT5UYT)8>3mtJIEV&>=-qM(>|A$zg8d}IIVyc^JW~t*NW5qknv^r}&gpwP&VVJi56v zveP!{?u(Y0NBX-n)i@p`PYC>Iw}8 zSIw%HpCesR7I`(vx?mq?EPqx=_dhQ)!)ztS_39UPF}%HgVNb}r$kh3dt2@@54v#(f zPVURe%IBZ>^}N&1Y`**EA~0cc7I;J!Gcbs$f-s|Jkje+3V5z5zV~9oX+ljY%8x(k4 z{gppkv`X*zuk(YM#XI^!ex8#;t;{LbZQHgzoAh}XtAN7vxkbym`D<4#)13O$w8Ej- zLZSM={Rff(w-p@U8f}>Vy!7nE2IY)RJ=Zse=gu|wI+@1ap!DeShwC0 ce<1XO@vyc6zvXszK2Uz~boFyt=akR{0K>>)g8%>k literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow@3x.png b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/darkgrey-shadow.imageset/darkgrey-shadow@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..f324520d705eb8345627a1784a99631c2ba4fa74 GIT binary patch literal 986 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-7USV3f`bi71Ki^|4CM&(%vz$xlkv ztH>-n*TA>HIW;5GqpB!1xXLdi zxhgx^GDXSWj?1RP3TQxXYDuC(MQ%=Bu~mhw64+cTAR8pCucQE0Qj%?}6yY17;GAES zs$i;Ts%M~N$E9FXl#*r@k>g7FXt#Bv$C=6)S^`fSBQuTAW;zSx}OhpQivaGchT@w8U0PNgrg1KGYVVbM@iw z1#;j%PR#>)vk2%PDX-P9fWg$5>FgX(9OUk#;OXjYW@u?n_`reqj=3xY{wx+JKj{fnvp5jwZ)t;Gd@#yBx z$WGg&yDwU19_jDSRO5J%JR$I--5#Ec`r5Ca@c(&se!}D!*F9c>y`htqoOmp_RpIJm zA>l>GQ*evWiOS>)9u>wE1!Sj*Yi$4v-$3ui@=1*S>O>_%)p>%0m6)~(+m@Uf;T)}978-h--cWiJ*>cC zbNS3=%}tKa->}VH#C=o#X)MQr%F46i-*x<}IA f{dna){fGRAUv6(zH4D9feq``;^>bP0l+XkKsd=0f literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/Contents.json new file mode 100644 index 000000000000..177c91924aa0 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "email.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/email.pdf b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/email.imageset/email.pdf new file mode 100644 index 0000000000000000000000000000000000000000..11c730458006635465da712532a515e870356653 GIT binary patch literal 7180 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 literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@2x.png b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..88a86b1f22e33df9fbadf5f3cbe8cb028e99661f GIT binary patch literal 1166 zcmV;91abR`P)w~t% z;j1dTb5~2qWfgOBt7|jR0x&YNun0?p#%h`FIBDwq>OU!1%Y6R@5viyoMn+avsyK#U z*x6It{OC+aXL$9Gr?;6X#Lt5OgfB-ZhPpiH(gU$FQ=Aiffzg zK1*%Kz_6OZ${nORh6+)H3me-#{K_Y2>qJ~MF>`Whne93=C>+DUz`&rO**}LEgAE-Y zeN)t$I8RW_H-w!-R)L9`lZ%m&nVFScT#{ed*;8Jq`|C3km zn#RH^C``yaW)?nvIko0~Q|IUZNOlYZ6Em~675;Q%>~Q}xzp%3>skv1^#NC%P$1pH3 zFgZm=iT8i`Z|w0JXB2Vs8JRQC3$RQ+a-4O~@Be(ue*D)DeS_g3VTs@*%(3;?fAx$HNM**dZ4C4TOaWOr$PQxN^ZP&R?%)4qnm_+Hae4lao6o?6t`1_Z zS-JwlK~T(Ff8;a+y#dpl3zu;@h$*(Fj#zWPv^xE#6!UYi2r#g2eDwjRgDib~>FFRX zac&(3)}258;c^h8hN&4n9b}~-V2j^Dnr7zobdbH0pcDQmv-0($r-Lly`K|FM>bTl^ zdOAo|giD=)xoXLBTn-ZKU2v3G2fc5!|3eJM<(Q96PX9PrnK&7k{4=vL9rNqI_NLkY zgI1XR7f|3bq*f6VV3euQ2%^ywv2M zL6Y1QHeMEC!tELcMpROzIMy8>rsW#VvX z(L6znqe#U`OHKcu?z2O2%z;d!ldQ~)`1}0qd@Q0a-P%`a;i%9RCjVDXwf_I5)#?AW zGOI^YeC*Q14Y}~Kh&r}to}`7Nf>)UQFK9NnrXCbbN~PV07*qoM6N<$f&utCX#fBK literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@3x.png b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/google.imageset/google@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b4d46452567dc373ce2e3c5d2ac2b2e62f9467ab GIT binary patch literal 1773 zcmVm@N};8cVg*VIw70y37FM2uR0I_#%5Y8) ze1O>mozWpUGNbW<2IrL5kb%>{bT~y(78H#xGMD&>njkPi=NOEDk6GdD55$RzT-w`G zc9QSUp3`5>x!?WHITwP+Y%qe*r%JtT9a1y-qJk1Py!L{pe{&lie6LRy-0{LYxN`tk zwmtR;{Pn)b`_yG8QQ;vw$CX*;mi|f@p;95EYA}LWFjSFFqP=C(pxfQvA&-DIfh$}4 zr%8&BTG>R+fnf?|!aN9KVruJPch^a!A$#wAA2aS zKOh_QW_T!WJhb5~UIsxhtXGnWnLy}<*Iv*!=*@7G{dk^c>L}0)iou?@7+1DEHZJaIwPHobk-GGF!rUDb1sWL-2JQG6>q#J?sIz!~rE#E!!aB5(OF0gUIjvb%A?fIY+QAkIhT}jLl2`lq1O6%oi1uhrwD+Gzc;9apR+ zB?G$R#~QT(iTy6#v1CT7wH?`t6&>dp(A@fZpSBJu8Kfk3rrm;MZBV@F`2`3OuP9>WETVUsWM;kirP6VSF?8Y13cnS zVyNJb7gnrU@sLqB7(viOcU!P}4KS~K20Z3=jR~`LNX>+dy}|OZaI9VfhQs8`g&+_BYWN2u1aoOuItX>1KGaA6n>jU!8w>N|sjcsrIDg;Hbng@Tq1$!CRMNffO zOxJ4_z`DFiF}J-C_Wvd9&bq+PAy{p9KcS`g^)JnnYQ_jBkD7Bj@S?35$Az--N_Y;7V$XdO-zmJ{xr z|FQ^{;bWo9>pFnyXfl{xfj>8~#Mp)|`%?V4D~Q4pJlTn_cF$OKXV+*b`;(Wztixd- zzDf?_tK>kkkh|5;uoFwAXNkg`8D_%&D*SgoXU~EA@q-|)%I6g-zDf?%rCv|Wm{gvj zP_8Vxs5e(NNCb5|+rMMvo=mC=8L|I5(+vWvGe>}4kHGc9lZJwQmng0#I$QhV+=|~k zCxt2Hw_CA#4Pe&Y1T&AQM@0>Xi<+ISZE*%?{Jw#D0*)twe5cus&d4!6ZMih9o5kkF;*hA76P9Zia zWTONO(`h%eX0(rIwl;(~Z638lo`SJxJh0)AEpyyYp&@iKP=P|L(AIT|N8>^Lu~E^_ zq+>Sk?O3_d<*YWinR_Jnxc=7jAsEu?hTp^zvy-Lp4=gB?J-xhBIj(ABW2j*4lQTMt z<`h%n*{cmMzDf?lcZhEZqiolhGN=w?9wbpR1$%>(Zny^?LHY z-{%$kgGXO|Fe<7yS2Y+moe?>%rQx2y*+K?R*H~We18!^!dM7MDa+($w?Jxx6sr+JN zz%8hhgP-;&Mxbhvy-uMBjB0tm2_f<{eu**Q;;ZCf_c#8aCtn55Etg-SQ&HW&vl1`h zn6dGzy+Dnt`Z#_SzSk^a@C_I&a5TU^2f7eK7(VP^UTpA1wQWr^kcnZcM zy;t14Kt^Izq2zacgJLV?1M)PdG80S8&TzLh7)%p{*`!7665k;kG`j+S?z2izVZk$% zOyC6@1hd3bLu9FrMKK3Fx^$VHpj?+GVU+C}ngwQST$hM2gjn*;q|Onnm9iB60cDPB zW5jN8OH7GpZ(O-l6I&@CP?yR2BlmjTSLL~0@yQSzcAIPe1zTznjnPp{GXKlJ>+5(@ P00000NkvXXu0mjfNET5! literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/Contents.json new file mode 100644 index 000000000000..ff89e46ad289 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "icon-password-field.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/icon-password-field.pdf b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-password-field.imageset/icon-password-field.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d4a27e75af0967ffaf1f10eebbfa05fee0b307e7 GIT binary patch literal 4050 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 literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/Contents.json new file mode 100644 index 000000000000..f8148aff0f0e --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "icon-post-search-highlight.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/icon-post-search-highlight.pdf b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/icon-post-search-highlight.imageset/icon-post-search-highlight.pdf new file mode 100644 index 0000000000000000000000000000000000000000..be4a1eab88cfc0a730414ed96fd57ceb45cf4ef6 GIT binary patch literal 4126 zcmai%c{r4P7soA=Wk@PZq}<7nY%^xYQrY(?TZ^%cF$QB9vM*V)Jho)ZQV2zMMG=PV z#AA{z35l^UDcR+nX?dTX=XtN|y{~)j`#0x4zjMy@JKsM(2coa0Ar6y(gCQ;C&*X*t zFArX~w1E)-6mYh(2Vb}VNb2GUw(fQSl43FhBsK7k?l@P<+Y#fAQ^R4Mt#N>Y0@%&n z6^C&Gd((Otb`hGPthF8{)*)h8B1zNfusilpAq=eqpj(T%K z`28tep@h?+uKGsJ2OX1iSi@Bc=ku#Wj-q1aat&X>IB9Lc1unGkCnn^%NZGW_XtF+; zY)~9>8YR&qfk`Rtk0REc?xTB@{xtj?6pCNZsWv3&(#y?ybQ4*wSKy&rno~C3vj5E+{2R`u_3OI(HrG+0iocBErsC-hq{w)%ba$Pw*3I_Dcuc74lEd|)xwGf&s?*Rq|ItfBoSU$f!;sHu}jV;q=QN|0a1()Zd?*^#4NEgN*l*lF2}X)X7q zO9SOdLwC;zWjjVnsdMlwM}DTGVM=O|t8-4vHMvsxmUwxuNurIF^5Ll)rMUNHohZa4Gs+)mD~O5)4h;-q}P&200tm_-pL zbd?2+rq@~HtX&~FVm!Iyp^lAoNA57XKL8_wC7^U-mIwKQ;4buI08MC+7CZf2dI>E$ zyb|cCvcg*$urll|t>-O!7n=TH-`AkaA<`}kW}xdY54y0h25BmR!y9#=v}3_|rjSqU zoH@#cceoxNN>dqRKW@V0qpGYg%R%3&8hM93L22u?(BorgZxv>%>dUX4cIT~OGQYL` zfblheVA!Me4?Ubp*M~XymgafTaQS&YCe5a<4{F|=_Zhigj!ayv=2Bp*YT}+a{)t8` z4BeE-q}0Jg->YHFbbM7!<&Lo+?cq}n@d6L&3e?l@usuBfB33K@#uPn6L^dY%7*`w5 zWCSW^!s;4k(CQ*|AQhW>O_zIG+J+q-ycP1uYT!wpv2d}XC$se7d$Hmz*DT{KP0LfB z(7(kjp66q0f64Qa&n2Mxn3)s-SqiE;G!#=Cw9tHTLTJ^y?(7)HO30x`w@+3#Pt}71 z`2tUl$MLr{2;X5g0Xc*mI&~KibQHY8&dW5zv=@4~31JZYSkGM`oZY9z%u3|R?YGKx z+I&Kn6FIcQ1-T6cMbavRuV~u~mWQ$ghSjR)1KfTSZ#k`iN{b zfYc=fCsZBLD^Qv%e*<}RidUuYkJGM~R>g7&}$Ymv1+}Ye{!7$NIO2+fQOEdh$l6pOqAdk9#&O`JYcWFCXQqy?-#BhquQCzpkl z`xAG+_%YYs8xz%huJIDq5;16Rm+bzlBgvB{3)|h7hGJ(tTbx=(-^vf~Rqu!%(m52b z8Qq?gpE$!yVzKxmrR;kA^|Gpj4XYLVm3)PB3Xux23Px=v)H!*v3d!E~)Uxq%- zY!B_|fn-6&%(LK!APvwpMg2Q)9K`>Cg>>!cWq zp(>^{VoYLyJb5nuCmFYH>7b*r5MDZR|Hpf;+PRFHDn~DT|2$5l!_ zeoDMpX0JJrKmTw(m^UtcnmF%QwUqI$ePpO%+$pe+HkQ7XQQ;&9UtM6whQ33IvQsPO z{X}syX(4C6rolE49jLaXwHi;-4AXqn-W2CCaK3*?=CMqYbW?0X+mp!~?aq52zN-2i zEt=DZ=f2nk$)1v1{n)`?FKhkzYm+FG?7{bgkt@J9x4-^DcTZ06+I^#fdoQQn7b;vgCIC z0son8^Bu{t^%{Tj?fs17^wA94%M$f|`xfuz=lsX^dKn82Stxe<%I?|iVHYQJgu!}e zGYb_<_69bGYzl~Liw}k@Nfm$C#)Vg_O716bmFYDfAQg~AH7YgcHA^%}F~X!^_TakHjP>kPpBH{5FF$Im zs9teBcrSYgyVf2*x2U(Nmz))==yj8M_p`TL zzoWjlyU4G)VZTO(Giv9gpfG7D3-gCk@q z^7;E-irfFN$^Dks-4%m(#JPSSx{dI@l=GtuK!(oF6n#)NKsiWlf{impEI-z26uDfG z#Y)M@B5>9Ss0|Va#X_avFeDTMleMw2p%fu2qX7Q@E{$v~B4nviL__wZ zuSs@Ul0AE5O};ZNzo+Nv_j-NL>%8XN^Euaj-S@e#&mZqg#85*^5+(%)i!@Dso}4RK zz5lkU1uP3d0RqMueD*9Lt%rAaAUXmFs$>jEYdN_R@gCHtE1HPcz+(wGJfNfm_9S}X z(QaTLdVOP6ca0Ow!~Wo2{V;Ik>uS-NBBs>5G)SF3#B(o??Mo#oUbKNxaA-DTaUw6R zw$Q^&Iv5`Dcw>l>#Rfg-cNQzebX`nbFEJVpa-0eomIy@q_EbxGzCCn*S>;L_v?b=Y zaKka27Sk;{CLR@sK(!A3T5ZmtusG5F=M4(x?{EjX@abBvcv2*3Vno~Usi#;zXGhBs zgd876PLC6u-rvKm!GuV^Z8rF5uyY`cBwn5+mSN>-z;sHtaK`6yN2#Oq#l$M-<&a>t zd9w}4Ca17cuZAQ|Um^rrfKA;IwB5aiB-*ffIt|gC_Zz1?Uzy46DQ$Q!IJO3x?^YxsLwYJ zMw)XD0sVWK*IvA3!zXR$^i?u>tp?vMyjWjbPZ&Uzs*s6?=mjrU^A@68D}?(Q??}P8 zHVep|qU1e>2Jf^(V7xo-X91`qTHR>HqCMeqvb2}9q0Db3HTAuW2c!+r4&Q%0@a{wa z{-eCccu&Gr4=mmjK>iTa3GPJddryF732OcI@i-^68o>v!f>H&TJRl=u1Ew0_=PI;W zzl>w(LBN{eiGUS#DGe>a29Q=KxDq@}E~Bw{faWZ9PZ%KgJ*|=w%|t(Q$p4n(r}-4V zM@gTjZc|#7WU?LRd|%12=DA`dB`*8@ALNX=qLkN4u~rl^|9} z(sx@eMhB)V`vx4A0{W`y48Pt0H4x3|gJiSKxfbW@tq*gyH*GNRveUDg)4v?Ew+Ep% zH5j_Tk`>d^9%{Fc))%FMa5e8G>I@;Cy?cT2LUQWD`{}@%J;<}yAv}`|NhYLbKAAnx z>$PMtnJ}0PcQ#ATBcbu8GS~HjDe;=N)D*}>Horv&-wWBy=o-V3bdj;#Xi&Cws>lb2 z+-ppDvZ+ow-Q>o}uCC2{dUQ~(G`ly`kZjio8BH#}#fZ-gbj(Rj3NJiZqqYWNqC{Ius;f^M~h=p64WBfvi zz`}w>@08n6L3ul#l{tU%@|nX1v57Nf57kEF@mbnC*FcaM>3lP}O!xb0V& z9D8K21kionM^`a&Z}oVXt8GHl$Ah8=qUPsaJ?XPtq{L_BcCu~|Ue|>wY^1d>?wFm) z=TQ#+OXmn2uQ{1tkbh3&&6|U)DHPo92K(OlO9iL@v^7fH%X-PP5;}9iXeNmzB*L*K z^lY$soD>cm64)~q$cq$Z;=7*V!sU3?jxHcn!LUk)=@mhdPW>-NVRvOehJZOMb{P0o z?F+|}_%hE}8NYAS+kywIBi+aJRE12Y)(^(wJVbIN_;N=>TAHjc-h$5GKwjQ&4Z8U9 z(B%UMgHS5qu=;aQ`jKEK<{QiGM{-n);<)qJ($sp{k6JSOs;e5xb1^onN5ruwsBFE3 zJmfufvnX5LP;u=qB7Zfr&CTumOm6|%{k!x5Asnd;UFbvobkBkY%FhTeqZ-;jX!snt z$HepU{g`eQw-Re*1JBseWjcvayM{z&l~>G+om%G1M^`k|;>?BVIZn953*|8sYNp4r z<{f<=qaA-`f^mO%HaeA;yM=E&+#-4mV}|a<=t6r^v8iTyJX1(}c6jjCjR%;X$NA=> zkCeSwkQ{eoB%91G#a^;1PkGGPkDfmxz}otf@1wxw>s7qgGVX{{P$gSmbWPA)2*y=r(G1pGif&&EtkB`O*w$zEnu~>p!Zm^xW0|>hco3isWPc!d^a5Tso z1wS+(3Wc%zHd$lD9^dX)eW4=&IiJX-6DG`KEG(8*5p1gCEMx;wgpf}pww=bIJa{cs zYz<0~UX16=`4wSn^ry3bX7ReaSS7$17*-fdo#TAg8OK-LJ*4Uk49 z!c$gL#@k8~i{o9A=TiJd43hd%yi@F?vPA7p1nPdv|60XhRIgHR5}aFZmaR2b(V!Tr zqnVzQjJbtL$3$Rmend1IiM&V%PN+O=P^dCn{!Zk<34XP%2fpvBD)cK(58?-*$53*4 zJ6(yh&1-@)r!BrQwHF~pDP)(FE)+<1D&-9qE0$m%lFS`)&yzYhZ4#ucH4EjwN;E4^KTU zDU=-bs#^#+lWtQ!%w2Q7hO*AOF0=#LfiWqD`-GEOH~NWQCu>3idN}50#;Q72qQ{v; z{Y4*l=@g7+SagVW9O}qDaFA!2r(2TOqw~sGm4HXQ6izDI&gXJ=_l5V#5x7jK5e`nXrLba$YJ>I;|9;6r;ph z>s4DEAias+W!UxF9$xKRp5E@;F#ySf9MgFEI(}5}*eLs61ZP9xq+aQo-E;3rP+wwA_)fxfi}-ZRip?he zYdk0={nH_GNh)vI4S^(_4*d}wH~qP!depATWbCO`FU@f`s%SSwR5p< zfSdNfa=TW04F2e-d*n#B(S4)x+Dne4rKuLR=W0e{WXTRIR@Y|NBd!a>_M{T$_bEcQ zonJ8RA0s~+hzupnjamd;+!}tf9N4j`-2y=_t5Zc^{ROq#=>^sZRrP_lF2up zb3RwrT%4~pLt9i>;7(_=K!m=8Wno_>}i4m_Igsiu}pHav@`|^?hI6s9Rtc zeGFqWlhSc6ffs?VHVj=#RNb16S{{#IX(jA__@eI5Sv+e9&waiLl0Tua^09SOa4GZP*#Y0kfTCH^-K_Oz zZ!IG&vwMenBcA0Uo&5FuM`we&GLCf|lT^ODcWo_tJuRK|+Tk1}zT?Qfy9!y}*FUSZ zgswzI@PwbfNK~q>n0S{_T3$NR9-LBUyYk$l@I86w>xGnJhf)QbGXWU^e743TV)1~ z`zVDJajgoiPpA?UC3-7**zfUL^KyH1O4oR3o!GZ$8_Au;S=YUH$PSpcre&ATxin?n zk%zSk+r8^<)3}+5E^nu${DR1l;#%eP;N9#U>{@I5?7YFIL2_1%viCKXub+J&ZuV27 z8zHMZe^ND#LcgOl5+?HtR%!f7)2NWEqpq%o_Qc}=8o(L@Horn>X#I{rWhQscA?6o!PtU~m~ILLMP+35AMM z@4q8|+YgocaaXa_7V-O<)Q&-&+8Gj^2<{p*WdEL%)EddoZ=JfP6?X;4{tYI**t!DVHr<@u)uh00Jr>tC9z9Q8#1r6Clk zXZBAG4p;bdE)wzQx^THa;@}7<)fWHChd}(PA!+CKD~{-ac5=mgeDA|eocyTgM{R(N z2?VM?Xda**q>j5ifohf?V-2cZ&SLBl7EFz+5pauRfO(W8K}Il;$`;trsgk~N@gMD(H%$jqk~hC(nP;7E7^prr-&rH~02 zZ*Ty+;c;DXGnBt>Qsju>^;Y7`mPy*30?j+nBU4;?n~@y?xEIFv37wi1??Ut>hJ~Wk zYp(NJqjgajRJ`!bXC%{_Jfthhqfk4uN!0iNhW}HDT=`W$FGxUdeEwA_kF2&P>cJ_B zMx`lIw3?((uy#@qK@WKB)GjqiHRMz*go-h@4)=Sn9{#zDKTsTVrlr1rCTfbz2 z4+TK{$cQz;mvo+tBlrR;KLmY}4~6;O7hq+PnX+R9JQ1Tu3IH6SOaZ3Gyq&Sm_D(kXp=EE!)plm?!BjO0wXubbm%8cv&Cu{~qBk43%+KZjifb60~ATRJz z!&n4l6=f1)$q^9-@iW+FB)miZ6#m2$c66jP&QO@|>J{t&?`I*)wni%hL&gx*lXt!p z^<_AHqup*~aPrZ>pxb=VKrNf)w=19~iYN5u$PjZX;W3oWZs=E7a zwt#%M@n}$#2{wPnzzrf}>Z$A6LZ!_r7Irwf`Pk@R0p|3@D*kIwv5<1obwhSpJMT32 zgt=15xjDQ33HQ~8!I6EJZ8kKY2Cko6fXvcw>r!vqjd;!8Ri=ruEy>v5+*?_na+0me zn@fi=de>qeVEep{t!ntzmwgdl&WSDW`Q`XxW@o*8*)u(r4o|7BXI>#aYlu)^PV4-* zeo7->Ogr>|i3oyV$jC3qH#2ztoL?Z7j^9`o+8leLPW)F#v%;;+Ct?fXQzxyal6k|T z-0Q-RhT6s};lW|SeWSs|UBs?i&XW{64uR#pf99Git6YZ8NdNABQK)W-Ey2jD&eLa_o%frrZG|LzSQ$ z3TL)Uh9J&z4g+jqA;v9lxS-mdg=z z`pNdQeEcD39dJaW8I*lEl*n`ClaNTZZgIS5u3(y8ztA3go(uZAmTJPBt@=^%LWw%7 zFCc}x4_z(J(zn!HJV24C<#D>Yc8B`~faKa>4+`6HgQEwt{T188kiiNKNgi}l=R1P{ zkz3qiPu`50R*Py0JZcgf-Sde}A>5@YiAU!t4`;WLEzh0>1HE`#8TK9fJrksJIf@L^ z;{|f})WjMmoEzulip;{?*e%*7J{D<59mSr)^kYq-eK&A7PMM2MsJIFtLRYWkVf*go z+sfV5_TyFAaWhu2<aoDqwgo|rTz8bV9DTpBXLq~4RZ0k_8`wIg8Q!nGF~z#gd}(-c{aj! zG$E}*3oR(p5kePQ9I^8EuD#NIY$6Fco+NA%AtPojBcE0kdcwp*+6kfwq3uuVID$u$ zciZVWTa?LU0xq(GiQ1OQ2O+{1rjmwwgU8iPYjiH?T+q+6-d^a_Yg>GyHpAY6W)Nf= zq>N5Nq<%>q>nKesN$^aWNxdj*kvx#Sw^E( zO`72*hUwWU*uSvp*eLAP_o!AY*~f{YiH~+#6zNP?yp+w`FQM0ycj0Aq)v>B0uL-ZA zd(o=7>pe-+t&37qv_)QmEWv?skRi#CU{pS{=4yhTSwUEf=TVcWb=0^o$cY;I82yb( zInjCZv3$zvA)iB+QlwK{Q`l4Dip@`w`h@#3ioJ_1dZ8W=4~)m4$9kVqNi4m9?thz} z;ey;8{}%b7qBbh9dd=f&^9RijNfPN2^%CL|CnZvmZ7A_{pY)D&M*1YOy~^F5?*Yi3 zVL$o68Jd#z@%af?*~%F8tkSZ%0>y5v+@TW9Qe0uBty|9V%C22biAoNtx{n+m+4$>O zm?_&TyOdkaXK7hwmS*NQpc@>5pFMd2ygn17d$X}HD>|zIa~qR0iaSu8P||%U$391+ zO%3<(T=at0m;c)J0)V!A5aLLCQ|vZkw%l)rX3;=GQIho=&)+_6UzLTxD1O z(8Pn%BE=EEhPfb(bf=0T(YoVx^d*5M>2=6Dj9W7@Ad)7q{EFgtur4g9Z^z8kXm!^D zb&Ol?qTIb6lY)^9yDs^z?Oi#1{9>QPdKGt*yU&eQOOg|m@JdvdfU{Y>C*P!u+0U#! zKQ<6M>DS`jGW1GwaHD$tu%MY>0y?HWxgcp$g3jmsS8Dm`gwy4Z5|^>_9`glSN3^1} zVzqYF`_-2ODX(BQI5zy&hQ17Znp_)Lw*aYu?($B7b3sO+Q`}m7aNeEU&V!m8*&FMw zNGZQ`k#X!5<9&_KVX&N%Mr%ctm= zSWz=kf1?g`t6?AdnBhgkR70~=TLrtyaU-fd70A~VmD(e6QO1v4wES|&EOKo2J$US+ z62kF%xq+XhbLnf zoK^_m58c`oT1xmyF%N*a-9QSu>tR4MY zKNIH-cpDFX>NM(%CF~jTi5~8?x?@#Qf5yFXexgmUM$c-Lrr6~m;MM9S;x#wqT3PDx zCRN(G`zyBdee`<^*|&)^BX&WjSBIW2h0W3Omh#s9-}&+Qza5&c{bHMjwR0YPQTEQM zl=1v?_UA`+r)TR=VI0n#5hTXpi_d7*|}^oF+n?PO|+Tba6(Mo>l&Z7yOX zwtrvU?dvYHmxYfuL$`~yOE>yf23(71i?eqI&d@9Wa*Kqz4 zSuM9{-bOE?A2zBo`hYG))2XY}p}>2Kt)DunsXb%i4f5X~E~j*tWM1}Pr@3J}T0VJp z&!lOa4j0y|uk|l^PvWP>d;E#>`32F#CH2}%p&ME2xW)E_=~;^vi?omczcR|C6d&6uO1dDlqsjSY`1mOJhQ=iN3xb#+QHxSO9AcIQTxgl87GGgtc!kYOzJ{&Pir3AfU`X_;W4-f%>P0g#NZKg^VG35y)G1%7z%o%pcPL zS(8Z2{$TY0Gm$1ft|Vr&{1_WB+vTW=Dhj3!SH&Pv1eA)3D+Ylh!0<4FIvj??<52{h c7Wn^%{Af2{3bQq~3W7kvnWkFS(837(Z}70Ct^fc4 literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/Contents.json new file mode 100644 index 000000000000..7ee24619adaf --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "key.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/key.pdf b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/key-icon.imageset/key.pdf new file mode 100644 index 0000000000000000000000000000000000000000..08fcc72ce3c50c0382e56cd56e1e4e81ac1ec990 GIT binary patch literal 3395 zcmbtX$!;4*5WV+T^u>UD;IQ`v5CmA16)3S{MN*K1&NDaD}cBB?2H|oLz!=x%#5=^ z3BV$gGB^u~Oem+F=Q7hqY7v2WFHJCfEg388b$1!R$N(dyttBhyIDvWqKS5Dv<@0cC zhU1BM7U0yo29l>DK5tZF6|Uspxb{gtp{o9`K2 zzWaJ?%I#IX*)cM6iwp4mD?hoc9=AEDdJB5^^5bD$9;*6rj^DlP_s`|FtQ#)a*%@D# z8>qd`_rK=bk}uya&KIi@y#Kl{K`;r))|+CtE|2@XC=Yr4Sh7KD+&`DmLuiV8-%Ou~O&`pYN@S=D*9N=K0Ao)fPRCPJ9wBPN?UjJ{FJEFYJoz`y1 zjvG*-?e68Fq1cubp(e(_*$RGAR*tE>R)SN#rR25e?`uRzoRWq_&9aAD4N0v3T3%F} z4ScSP^2k3y(-Im#&jnGpt@1yrb@O!ORAaXtbh)dW^Yh&ox}Ayi`O%L~_mZw%=gpy_ zi}L#ye{j^RYJ((&olEz--|LF<%&FQ3N2|lJA@)#HZ8RV98F1TGc;Yq%jj#S;dFX00 zM$D4AMDeBMlr_o#z3*N+ypry^0ABR#cXm8T1$%jn{l(vnXV+mT%b;EQaW# zp~mB!5l#WsnFuC2$yFw#7g6wMU@9sT1Tf{CgiQ_gK1xEgiBd@*A;_TJh!jksK`IEE z5qw4;!Cf1z=O)w6DJi)$nGG=*gP@#2Sb$s>gp+8K0|b2$MyxjzP>}=5BQ%}CU1;GW z1o~oXtOXmvg~||gL^Pz%dt)Tg(vdmZ9WfX-@R>ZDN~+9j+!uUdGU;55iX&iWCG~XV zDFkwi7Yt@%j6(4RXp>RG;4{FK#so&Af|opI!o{Er$we!rS3G3i8XaL5SCLp;Ef}cr z!N@>Hyp7&D=mSkiIKqdd&_RS2RKYlxK-5Z*4y1b@G$s+`bgRZ>#yAyXx@sJPf)VmR zI)keyo**f9zTBY~i!e))A`+LD zY&Zy_Kmdx&B6Le^d9Y<2CgT*0nU)|KE}|}D)QAo)iYyu%1d@uX_DVZVD2h&tU=iaK zMa>{?;YfqGmd=stpoImelvWA1!lW2SELEe_0VXDriXN)dolgc20}pAyhR{)u1E?~i zl~k}CGZd>vA>d(1dXE#s1C)v}kb`C-Hw1#>c3OLAL&SJNkRnT|h6;v=l(H0MkEO>w zOUyI12A?4Z(xcb)j|f}L1PnSPGaju29YI5-(GL0KGN2n_*O@>qS=K7N(@`#ha}`lB zB1=`8UgQ7`PZzv&Amxs_A08UGYSp216xyLgOoQDKgWm zjxXty9p)5Tc8}9odso-H2Aja^*?N;>i)v@)b-Awc?~Qwfg*8}dQK)b*qD$C*nZOR% zZjVP!gR~#YD~wTWQAbYmvIlQ++N_|X!zN=jVdE73rCQV4l7hG$Os#e=biLmO(h5Z( z9Dz)Ajr%i&o+L)22)A3GR(X>@?;c-Y{&K*&i2~Iu6eH>#c=sW{=)>zClITfu zNQ01>6gu<_vhEvQ`2WLPDn@KPuXIUa<`JfWFX#Ng?n=8#v}G literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/Contents.json b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/Contents.json new file mode 100644 index 000000000000..bc416ad2f8a0 --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "login-magic-link.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/login-magic-link.pdf b/WordPressAuthenticator/Sources/Resources/Assets.xcassets/login-magic-link.imageset/login-magic-link.pdf new file mode 100644 index 0000000000000000000000000000000000000000..81c37e8ea531062b80701f454d21f8b6c0356dea GIT binary patch literal 436869 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 z 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

") else { + // Each "version" in the changelog is delineated by the version number wrapped in `

`. + // If the payload doesn't follow the format we're expecting, let's not trim it and return what we received. + return log + } + + let rangeAfterFirstOccurence = firstOccurence.upperBound ..< log.endIndex + guard let secondOccurence = log.range(of: "

", range: rangeAfterFirstOccurence) else { + // Same as above. If the data doesn't the format we're expecting, bail. + return log + } + + return String(log[log.startIndex ..< secondOccurence.lowerBound]) +} + +private final class AuthorParser: NSObject, XMLParserDelegate { + var author = "" + var url: URL? + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + guard elementName == "a", + let href = attributeDict["href"] else { + return + } + url = URL(string: href) + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + author.append(string) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryFeedPage.swift b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryFeedPage.swift new file mode 100644 index 000000000000..af6d1d2c7c8e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryFeedPage.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct PluginDirectoryFeedPage: Decodable, Equatable { + public let pageMetadata: PluginDirectoryPageMetadata + public let plugins: [PluginDirectoryEntry] + + private enum CodingKeys: String, CodingKey { + case info + case plugins + } + + private enum InfoKeys: String, CodingKey { + case page + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // The API we're using has a bug where sometimes the `plugins` field is an Array, and sometimes + // it's a dictionary with numerical keys. Until the responsible parties can deploy a patch, + // here's a workaround. + do { + if let parsedPlugins = try? container.decode([PluginDirectoryEntry].self, forKey: .plugins) { + plugins = parsedPlugins + } else { + let parsedPlugins = try container.decode([Int: PluginDirectoryEntry].self, forKey: .plugins) + plugins = parsedPlugins + .sorted { $0.key < $1.key } + .compactMap { $0.value } + } + } + + let info = try container.nestedContainer(keyedBy: InfoKeys.self, forKey: .info) + + let pageNumber = try info.decode(Int.self, forKey: .page) + + pageMetadata = PluginDirectoryPageMetadata(page: pageNumber, pluginSlugs: plugins.map { $0.slug}) + } + + public static func ==(lhs: PluginDirectoryFeedPage, rhs: PluginDirectoryFeedPage) -> Bool { + return lhs.pageMetadata == rhs.pageMetadata + && lhs.plugins == rhs.plugins + } + +} + +public struct PluginDirectoryPageMetadata: Equatable, Codable { + public let page: Int + public let pluginSlugs: [String] + + public static func ==(lhs: PluginDirectoryPageMetadata, rhs: PluginDirectoryPageMetadata) -> Bool { + return lhs.page == rhs.page + && lhs.pluginSlugs == rhs.pluginSlugs + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginState.swift b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginState.swift new file mode 100644 index 000000000000..62f0dbea6a64 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginState.swift @@ -0,0 +1,121 @@ +import Foundation + +public struct PluginState: Equatable, Codable { + public enum UpdateState: Equatable, Codable { + public static func ==(lhs: PluginState.UpdateState, rhs: PluginState.UpdateState) -> Bool { + switch (lhs, rhs) { + case (.updated, .updated): + return true + case (.available(let lhsValue), .available(let rhsValue)): + return lhsValue == rhsValue + case (.updating(let lhsValue), .updating(let rhsValue)): + return lhsValue == rhsValue + default: + return false + } + } + + private enum CodingKeys: String, CodingKey { + case updated + case available + case updating + } + + case updated + case available(String) + case updating(String) + + public func encode(to encoder: Encoder) throws { + var encoder = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .updated: + try encoder.encode(true, forKey: .updated) + case .available(let value): + try encoder.encode(value, forKey: .available) + case .updating(let value): + try encoder.encode(value, forKey: .updating) + } + } + + public init(from decoder: Decoder) throws { + let decoder = try decoder.container(keyedBy: CodingKeys.self) + + if let _ = try decoder.decodeIfPresent(Bool.self, forKey: .updated) { + self = .updated + return + } + + if let value = try decoder.decodeIfPresent(String.self, forKey: .available) { + self = .available(value) + return + } + + if let value = try decoder.decodeIfPresent(String.self, forKey: .updating) { + self = .updating(value) + return + } + + self = .updated + } + } + + public let id: String + public let slug: String + public var active: Bool + public let name: String + public let author: String + public let version: String? + public var updateState: UpdateState + public var autoupdate: Bool + public var automanaged: Bool + public let url: URL? + public let settingsURL: URL? + + public static func ==(lhs: PluginState, rhs: PluginState) -> Bool { + return lhs.id == rhs.id + && lhs.slug == rhs.slug + && lhs.active == rhs.active + && lhs.name == rhs.name + && lhs.version == rhs.version + && lhs.updateState == rhs.updateState + && lhs.autoupdate == rhs.autoupdate + && lhs.automanaged == rhs.automanaged + && lhs.url == rhs.url + } +} + +public extension PluginState { + var stateDescription: String { + if automanaged { + return NSLocalizedString("Auto-managed on this site", comment: "The plugin can not be manually updated or deactivated") + } + switch (active, autoupdate) { + case (false, false): + return NSLocalizedString("Inactive, Autoupdates off", comment: "The plugin is not active on the site and has not enabled automatic updates") + case (false, true): + return NSLocalizedString("Inactive, Autoupdates on", comment: "The plugin is not active on the site and has enabled automatic updates") + case (true, false): + return NSLocalizedString("Active, Autoupdates off", comment: "The plugin is active on the site and has not enabled automatic updates") + case (true, true): + return NSLocalizedString("Active, Autoupdates on", comment: "The plugin is active on the site and has enabled automatic updates") + } + } + + var homeURL: URL? { + return url + } + + var directoryURL: URL? { + return URL(string: "https://wordpress.org/plugins/\(slug)") + } + + var deactivateAllowed: Bool { + return !isJetpack && !automanaged + } + + var isJetpack: Bool { + return slug == "jetpack" + || slug == "jetpack-dev" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePlugin.swift b/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePlugin.swift new file mode 100644 index 000000000000..df35faaab00d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePlugin.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct SitePlugins: Codable { + public var plugins: [PluginState] + public var capabilities: SitePluginCapabilities + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePluginCapabilities.swift b/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePluginCapabilities.swift new file mode 100644 index 000000000000..60f828094df7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Plugins/SitePluginCapabilities.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct SitePluginCapabilities: Equatable, Codable { + public let modify: Bool + public let autoupdate: Bool + + public static func ==(lhs: SitePluginCapabilities, rhs: SitePluginCapabilities) -> Bool { + return lhs.modify == rhs.modify + && lhs.autoupdate == rhs.autoupdate + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/ReaderFeed.swift b/WordPressKit/Sources/WordPressKit/Models/ReaderFeed.swift new file mode 100644 index 000000000000..fb4971f73d72 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/ReaderFeed.swift @@ -0,0 +1,75 @@ +import Foundation + +/// ReaderFeed +/// Encapsulates details of a single feed returned by the Reader feed search API +/// (read/feed?q=query) +/// +public struct ReaderFeed: Decodable { + public let url: URL + public let title: String + public let feedDescription: String? + public let feedID: String? + public let blogID: String? + public let blavatarURL: URL? + + private enum CodingKeys: String, CodingKey { + case url = "URL" + case title = "title" + case feedID = "feed_ID" + case blogID = "blog_ID" + case meta = "meta" + } + + private enum MetaKeys: CodingKey { + case data + } + + private enum DataKeys: CodingKey { + case site + } + + private enum SiteKeys: CodingKey { + case description + case icon + } + + private enum IconKeys: CodingKey { + case img + } + + public init(from decoder: Decoder) throws { + // We have to manually decode the feed from the JSON, for a couple of reasons: + // - Some feeds have no `icon` dictionary + // - Some feeds have no `data` dictionary + // - We want to decode whatever we can get, and not fail if neither of those exist + let rootContainer = try decoder.container(keyedBy: CodingKeys.self) + + url = try rootContainer.decode(URL.self, forKey: .url) + title = try rootContainer.decode(String.self, forKey: .title) + feedID = try? rootContainer.decode(String.self, forKey: .feedID) + blogID = try? rootContainer.decode(String.self, forKey: .blogID) + + var feedDescription: String? + var blavatarURL: URL? + + do { + let metaContainer = try rootContainer.nestedContainer(keyedBy: MetaKeys.self, forKey: .meta) + let dataContainer = try metaContainer.nestedContainer(keyedBy: DataKeys.self, forKey: .data) + let siteContainer = try dataContainer.nestedContainer(keyedBy: SiteKeys.self, forKey: .site) + feedDescription = try? siteContainer.decode(String.self, forKey: .description) + + let iconContainer = try siteContainer.nestedContainer(keyedBy: IconKeys.self, forKey: .icon) + blavatarURL = try? iconContainer.decode(URL.self, forKey: .img) + } catch { + } + + self.feedDescription = feedDescription + self.blavatarURL = blavatarURL + } +} + +extension ReaderFeed: CustomStringConvertible { + public var description: String { + return "" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlockEditorSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlockEditorSettings.swift new file mode 100644 index 000000000000..cd569cf27f9f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlockEditorSettings.swift @@ -0,0 +1,82 @@ +import Foundation + +public class RemoteBlockEditorSettings: Codable { + enum CodingKeys: String, CodingKey { + case isFSETheme = "__unstableIsBlockBasedTheme" + case galleryWithImageBlocks = "__unstableGalleryWithImageBlocks" + case quoteBlockV2 = "__experimentalEnableQuoteBlockV2" + case listBlockV2 = "__experimentalEnableListBlockV2" + case rawStyles = "__experimentalStyles" + case rawFeatures = "__experimentalFeatures" + case colors + case gradients + } + + public let isFSETheme: Bool + public let galleryWithImageBlocks: Bool + public let quoteBlockV2: Bool + public let listBlockV2: Bool + public let rawStyles: String? + public let rawFeatures: String? + public let colors: [[String: String]]? + public let gradients: [[String: String]]? + + public lazy var checksum: String = { + return ChecksumUtil.checksum(from: self) + }() + + private static func parseToString(_ container: KeyedDecodingContainer, _ key: CodingKeys) -> String? { + // Swift cuurently doesn't support type conversions from Dictionaries to strings while decoding. So we need to + // parse the reponse then convert it to a string. + guard + let json = try? container.decode([String: Any].self, forKey: key), + let data = try? JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + else { + return nil + } + return String(data: data, encoding: .utf8) + } + + required public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + self.isFSETheme = (try? map.decode(Bool.self, forKey: .isFSETheme)) ?? false + self.galleryWithImageBlocks = (try? map.decode(Bool.self, forKey: .galleryWithImageBlocks)) ?? false + self.quoteBlockV2 = (try? map.decode(Bool.self, forKey: .quoteBlockV2)) ?? false + self.listBlockV2 = (try? map.decode(Bool.self, forKey: .listBlockV2)) ?? false + self.rawStyles = RemoteBlockEditorSettings.parseToString(map, .rawStyles) + self.rawFeatures = RemoteBlockEditorSettings.parseToString(map, .rawFeatures) + self.colors = try? map.decode([[String: String]].self, forKey: .colors) + self.gradients = try? map.decode([[String: String]].self, forKey: .gradients) + } +} + +// MARK: EditorTheme +public class RemoteEditorTheme: Codable { + enum CodingKeys: String, CodingKey { + case themeSupport = "theme_supports" + } + + public let themeSupport: RemoteEditorThemeSupport? + public lazy var checksum: String = { + return ChecksumUtil.checksum(from: themeSupport) + }() +} + +public struct RemoteEditorThemeSupport: Codable { + enum CodingKeys: String, CodingKey { + case colors = "editor-color-palette" + case gradients = "editor-gradient-presets" + case blockTemplates = "block-templates" + } + + public let colors: [[String: String]]? + public let gradients: [[String: String]]? + public let blockTemplates: Bool + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + self.colors = try? map.decode([[String: String]].self, forKey: .colors) + self.gradients = try? map.decode([[String: String]].self, forKey: .gradients) + self.blockTemplates = (try? map.decode(Bool.self, forKey: .blockTemplates)) ?? false + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlog.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlog.swift new file mode 100644 index 000000000000..3351e05941ab --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlog.swift @@ -0,0 +1,84 @@ +import Foundation +import NSObject_SafeExpectations + +/// This class encapsulates all of the *remote* Blog properties +@objcMembers public class RemoteBlog: NSObject { + + /// The ID of the Blog entity. + public var blogID: NSNumber + + /// The organization ID of the Blog entity. + public var organizationID: NSNumber + + /// Represents the Blog Name. + public var name: String + + /// Description of the WordPress Blog. + public var tagline: String? + + /// Represents the Blog Name. + public var url: String + + /// Maps to the XMLRPC endpoint. + public var xmlrpc: String? + + /// Site Icon's URL. + public var icon: String? + + /// Product ID of the site's current plan, if it has one. + public var planID: NSNumber? + + /// Product name of the site's current plan, if it has one. + public var planTitle: String? + + /// Indicates whether the current's blog plan is paid, or not. + public var hasPaidPlan: Bool = false + + /// Features available for the current blog's plan. + public var planActiveFeatures = [String]() + + /// Indicates whether it's a jetpack site, or not. + public var jetpack: Bool = false + + /// Boolean indicating whether the current user has Admin privileges, or not. + public var isAdmin: Bool = false + + /// Blog's visibility preferences. + public var visible: Bool = false + + /// Blog's options preferences. + public var options: NSDictionary + + /// Blog's capabilities: Indicate which actions are allowed / not allowed, for the current user. + public var capabilities: [String: Bool] + + /// Blog's total disk quota space. + public var quotaSpaceAllowed: NSNumber? + + /// Blog's total disk quota space used. + public var quotaSpaceUsed: NSNumber? + + /// Parses details from a JSON dictionary, as returned by the WordPress.com REST API. + @objc(initWithJSONDictionary:) + public init(jsonDictionary json: NSDictionary) { + self.blogID = json.number(forKey: "ID") ?? 0 + self.organizationID = json.number(forKey: "organization_id") ?? 0 + self.name = json.string(forKey: "name") ?? "" + self.tagline = json.string(forKey: "description") + self.url = json.string(forKey: "URL") ?? "" + self.xmlrpc = json.string(forKeyPath: "meta.links.xmlrpc") + self.jetpack = json.number(forKey: "jetpack")?.boolValue ?? false + self.icon = json.string(forKeyPath: "icon.img") + self.capabilities = json.object(forKey: "capabilities") as? [String: Bool] ?? [:] + self.isAdmin = json.number(forKeyPath: "capabilities.manage_options")?.boolValue ?? false + self.visible = json.number(forKey: "visible")?.boolValue ?? false + self.options = RemoteBlogOptionsHelper.mapOptions(fromResponse: json) + self.planID = json.number(forKeyPath: "plan.product_id") + self.planTitle = json.string(forKeyPath: "plan.product_name_short") + self.hasPaidPlan = !(json.number(forKeyPath: "plan.is_free")?.boolValue ?? true) + self.planActiveFeatures = (json.array(forKeyPath: "plan.features.active") as? [String]) ?? [] + self.quotaSpaceAllowed = json.number(forKeyPath: "quota.space_allowed") + self.quotaSpaceUsed = json.number(forKeyPath: "quota.space_used") + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackModulesSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackModulesSettings.swift new file mode 100644 index 000000000000..0c3ce7da9e70 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackModulesSettings.swift @@ -0,0 +1,20 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack modules settings available for a Blog entity +/// +public struct RemoteBlogJetpackModulesSettings { + + /// Indicates whether the Jetpack site lazy loads images. + /// + public let lazyLoadImages: Bool + + /// Indicates whether the Jetpack site serves images from our server. + /// + public let serveImagesFromOurServers: Bool + + public init(lazyLoadImages: Bool, serveImagesFromOurServers: Bool) { + self.lazyLoadImages = lazyLoadImages + self.serveImagesFromOurServers = serveImagesFromOurServers + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackMonitorSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackMonitorSettings.swift new file mode 100644 index 000000000000..3d822a8b094d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackMonitorSettings.swift @@ -0,0 +1,21 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack monitor settings available for a Blog entity +/// +public struct RemoteBlogJetpackMonitorSettings { + + /// Indicates whether the Jetpack site's monitor notifications should be sent by email + /// + public let monitorEmailNotifications: Bool + + /// Indicates whether the Jetpack site's monitor notifications should be sent by push notifications + /// + public let monitorPushNotifications: Bool + + public init(monitorEmailNotifications: Bool, + monitorPushNotifications: Bool) { + self.monitorEmailNotifications = monitorEmailNotifications + self.monitorPushNotifications = monitorPushNotifications + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackSettings.swift new file mode 100644 index 000000000000..75c9dc4c0477 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogJetpackSettings.swift @@ -0,0 +1,44 @@ +import Foundation + +/// This struct encapsulates the *remote* Jetpack settings available for a Blog entity +/// +public struct RemoteBlogJetpackSettings { + + /// Indicates whether the Jetpack site's monitor is on or off + /// + public let monitorEnabled: Bool + + /// Indicates whether Jetpack will block malicious login attemps + /// + public let blockMaliciousLoginAttempts: Bool + + /// List of IP addresses that will never be blocked for logins by Jetpack + /// + public let loginAllowListedIPAddresses: Set + + /// Indicates whether WordPress.com SSO is enabled for the Jetpack site + /// + public let ssoEnabled: Bool + + /// Indicates whether SSO will try to match accounts by email address + /// + public let ssoMatchAccountsByEmail: Bool + + /// Indicates whether to force or not two-step authentication when users log in via WordPress.com + /// + public let ssoRequireTwoStepAuthentication: Bool + + public init(monitorEnabled: Bool, + blockMaliciousLoginAttempts: Bool, + loginAllowListedIPAddresses: Set, + ssoEnabled: Bool, + ssoMatchAccountsByEmail: Bool, + ssoRequireTwoStepAuthentication: Bool) { + self.monitorEnabled = monitorEnabled + self.blockMaliciousLoginAttempts = blockMaliciousLoginAttempts + self.loginAllowListedIPAddresses = loginAllowListedIPAddresses + self.ssoEnabled = ssoEnabled + self.ssoMatchAccountsByEmail = ssoMatchAccountsByEmail + self.ssoRequireTwoStepAuthentication = ssoRequireTwoStepAuthentication + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlogOptionsHelper.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogOptionsHelper.swift new file mode 100644 index 000000000000..184960f36a0d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogOptionsHelper.swift @@ -0,0 +1,79 @@ +import Foundation + +@objcMembers public class RemoteBlogOptionsHelper: NSObject { + + public class func mapOptions(fromResponse response: NSDictionary) -> NSDictionary { + let options = NSMutableDictionary() + options["home_url"] = response["URL"] + if response.number(forKey: "jetpack")?.boolValue == true { + options["jetpack_client_id"] = response.number(forKey: "ID") + } + if response["options"] != nil { + options["post_thumbnail"] = response.value(forKeyPath: "options.featured_images_enabled") + + let optionsDirectMapKeys = [ + "active_modules", + "admin_url", + "login_url", + "unmapped_url", + "image_default_link_type", + "software_version", + "videopress_enabled", + "timezone", + "gmt_offset", + "allowed_file_types", + "frame_nonce", + "jetpack_version", + "is_automated_transfer", + "blog_public", + "max_upload_size", + "is_wpcom_atomic", + "is_wpforteams_site", + "show_on_front", + "page_on_front", + "page_for_posts", + "blogging_prompts_settings", + "jetpack_connection_active_plugins", + "can_blaze" + ] + for key in optionsDirectMapKeys { + if let value = response.value(forKeyPath: "options.\(key)") { + options[key] = value + } + } + } + let valueOptions = NSMutableDictionary(capacity: options.count) + for (key, obj) in options { + valueOptions[key] = [ + "value": obj + ] + } + + return NSDictionary(dictionary: valueOptions) + } + + // Helper methods for converting between XMLRPC dictionaries and RemoteBlogSettings + // Currently, we are only ever updating the blog title or tagline through XMLRPC + // Brent - Jan 7, 2017 + public class func remoteOptionsForUpdatingBlogTitleAndTagline(_ blogSettings: RemoteBlogSettings) -> NSDictionary { + let options = NSMutableDictionary() + if let value = blogSettings.name { + options["blog_title"] = value + } + if let value = blogSettings.tagline { + options["blog_tagline"] = value + } + return options + } + + public class func remoteBlogSettings(fromXMLRPCDictionaryOptions options: NSDictionary) -> RemoteBlogSettings { + let remoteSettings = RemoteBlogSettings() + remoteSettings.name = options.string(forKeyPath: "blog_title.value")?.stringByDecodingXMLCharacters() + remoteSettings.tagline = options.string(forKeyPath: "blog_tagline.value")?.stringByDecodingXMLCharacters() + if options["blog_public"] != nil { + remoteSettings.privacy = options.number(forKeyPath: "blog_public.value") + } + return remoteSettings + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBlogSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogSettings.swift new file mode 100644 index 000000000000..bd395c7b1a53 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBlogSettings.swift @@ -0,0 +1,206 @@ +import Foundation + +/// This class encapsulates all of the *remote* settings available for a Blog entity +/// +public class RemoteBlogSettings: NSObject { + // MARK: - General + + /// Represents the Blog Name. + /// + @objc public var name: String? + + /// Stores the Blog's Tagline setting. + /// + @objc public var tagline: String? + + /// Stores the Blog's Privacy Preferences Settings + /// + @objc public var privacy: NSNumber? + + /// Stores the Blog's Language ID Setting + /// + @objc public var languageID: NSNumber? + + /// Stores the Blog's Icon Media ID + /// + @objc public var iconMediaID: NSNumber? + + /// Stores the Blog's GMT offset + /// + @objc public var gmtOffset: NSNumber? + + /// Stores the Blog's timezone + /// + @objc public var timezoneString: String? + + // MARK: - Writing + + /// Contains the Default Category ID. Used when creating new posts. + /// + @objc public var defaultCategoryID: NSNumber? + + /// Contains the Default Post Format. Used when creating new posts. + /// + @objc public var defaultPostFormat: String? + + /// The blog's date format setting. + /// + @objc public var dateFormat: String? + + /// The blog's time format setting + /// + @objc public var timeFormat: String? + + /// The blog's chosen day to start the week setting + /// + @objc public var startOfWeek: String? + + /// Numbers of posts per page + /// + @objc public var postsPerPage: NSNumber? + + // MARK: - Discussion + + /// Represents whether comments are allowed, or not. + /// + @objc public var commentsAllowed: NSNumber? + + /// Contains a list of words that would automatically blocklist a comment. + /// + @objc public var commentsBlocklistKeys: String? + + /// If true, comments will be automatically closed after the number of days, specified by `commentsCloseAutomaticallyAfterDays`. + /// + @objc public var commentsCloseAutomatically: NSNumber? + + /// Represents the number of days comments will be enabled, granted that the `commentsCloseAutomatically` + /// property is set to true. + /// + @objc public var commentsCloseAutomaticallyAfterDays: NSNumber? + + /// When enabled, comments from known users will be allowlisted. + /// + @objc public var commentsFromKnownUsersAllowlisted: NSNumber? + + /// Indicates the maximum number of links allowed per comment. When a new comment exceeds this number, + /// it'll be held in queue for moderation. + /// + @objc public var commentsMaximumLinks: NSNumber? + + /// Contains a list of words that cause a comment to require moderation. + /// + @objc public var commentsModerationKeys: String? + + /// If true, comment pagination will be enabled. + /// + @objc public var commentsPagingEnabled: NSNumber? + + /// Specifies the number of comments per page. This will be used only if the property `commentsPagingEnabled` + /// is set to true. + /// + @objc public var commentsPageSize: NSNumber? + + /// When enabled, new comments will require Manual Moderation, before showing up. + /// + @objc public var commentsRequireManualModeration: NSNumber? + + /// If set to true, commenters will be required to enter their name and email. + /// + @objc public var commentsRequireNameAndEmail: NSNumber? + + /// Specifies whether commenters should be registered or not. + /// + @objc public var commentsRequireRegistration: NSNumber? + + /// Indicates the sorting order of the comments. Ascending / Descending, based on the date. + /// + @objc public var commentsSortOrder: String? + + /// Indicates the number of levels allowed per comment. + /// + @objc public var commentsThreadingDepth: NSNumber? + + /// When enabled, comment threading will be supported. + /// + @objc public var commentsThreadingEnabled: NSNumber? + + /// If set to true, 3rd party sites will be allowed to post pingbacks. + /// + @objc public var pingbackInboundEnabled: NSNumber? + + /// When Outbound Pingbacks are enabled, 3rd party sites that get linked will be notified. + /// + @objc public var pingbackOutboundEnabled: NSNumber? + + // MARK: - Related Posts + + /// When set to true, Related Posts will be allowed. + /// + @objc public var relatedPostsAllowed: NSNumber? + + /// When set to true, Related Posts will be enabled. + /// + @objc public var relatedPostsEnabled: NSNumber? + + /// Indicates whether related posts should show a headline. + /// + @objc public var relatedPostsShowHeadline: NSNumber? + + /// Indicates whether related posts should show thumbnails. + /// + @objc public var relatedPostsShowThumbnails: NSNumber? + + // MARK: - AMP + + /// Indicates if AMP is supported on the site + /// + @objc public var ampSupported: NSNumber? + + /// Indicates if AMP is enabled on the site + /// + @objc public var ampEnabled: NSNumber? + + // MARK: - Sharing + + /// Indicates the style to use for the sharing buttons on a particular blog.. + /// + @objc public var sharingButtonStyle: String? + + /// The title of the sharing label on the user's blog. + /// + @objc public var sharingLabel: String? + + /// Indicates the twitter username to use when sharing via Twitter + /// + @objc public var sharingTwitterName: String? + + /// Indicates whether related posts should show thumbnails. + /// + @objc public var sharingCommentLikesEnabled: NSNumber? + + /// Indicates whether sharing via post likes has been disabled + /// + @objc public var sharingDisabledLikes: NSNumber? + + /// Indicates whether sharing by reblogging has been disabled + /// + @objc public var sharingDisabledReblogs: NSNumber? + + // MARK: - Helpers + + /// Computed property, meant to help conversion from Remote / String-Based values, into their Integer counterparts + /// + @objc public var commentsSortOrderAscending: Bool { + set { + commentsSortOrder = newValue ? RemoteBlogSettings.AscendingStringValue : RemoteBlogSettings.DescendingStringValue + } + get { + return commentsSortOrder == RemoteBlogSettings.AscendingStringValue + } + } + + // MARK: - Private + + private static let AscendingStringValue = "asc" + private static let DescendingStringValue = "desc" +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPrompt.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPrompt.swift new file mode 100644 index 000000000000..8fc2be4665d5 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPrompt.swift @@ -0,0 +1,58 @@ +public struct RemoteBloggingPrompt { + public var promptID: Int + public var text: String + public var title: String + public var content: String + public var attribution: String + public var date: Date + public var answered: Bool + public var answeredUsersCount: Int + public var answeredUserAvatarURLs: [URL] +} + +// MARK: - Decodable + +extension RemoteBloggingPrompt: Decodable { + enum CodingKeys: String, CodingKey { + case id + case text + case title + case content + case attribution + case date + case answered + case answeredUsersCount = "answered_users_count" + case answeredUserAvatarURLs = "answered_users_sample" + } + + /// Used to format the fetched object's date string to a date. + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.timeZone = .init(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.promptID = try container.decode(Int.self, forKey: .id) + self.text = try container.decode(String.self, forKey: .text) + self.title = try container.decode(String.self, forKey: .title) + self.content = try container.decode(String.self, forKey: .content) + self.attribution = try container.decode(String.self, forKey: .attribution) + self.answered = try container.decode(Bool.self, forKey: .answered) + self.date = Self.dateFormatter.date(from: try container.decode(String.self, forKey: .date)) ?? Date() + self.answeredUsersCount = try container.decode(Int.self, forKey: .answeredUsersCount) + + let userAvatars = try container.decode([UserAvatar].self, forKey: .answeredUserAvatarURLs) + self.answeredUserAvatarURLs = userAvatars.compactMap { URL(string: $0.avatar) } + } + + /// meta structure to simplify decoding logic for user avatar objects. + /// this is intended to be private. + private struct UserAvatar: Codable { + var avatar: String + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPromptsSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPromptsSettings.swift new file mode 100644 index 000000000000..7bc6363100c9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteBloggingPromptsSettings.swift @@ -0,0 +1,51 @@ +public struct RemoteBloggingPromptsSettings: Codable { + public var promptCardEnabled: Bool + public var promptRemindersEnabled: Bool + public var reminderDays: ReminderDays + public var reminderTime: String + public var isPotentialBloggingSite: Bool + + public struct ReminderDays: Codable { + public var monday: Bool + public var tuesday: Bool + public var wednesday: Bool + public var thursday: Bool + public var friday: Bool + public var saturday: Bool + public var sunday: Bool + + public init(monday: Bool, tuesday: Bool, wednesday: Bool, thursday: Bool, friday: Bool, saturday: Bool, sunday: Bool) { + self.monday = monday + self.tuesday = tuesday + self.wednesday = wednesday + self.thursday = thursday + self.friday = friday + self.saturday = saturday + self.sunday = sunday + } + } + + private enum CodingKeys: String, CodingKey { + case promptCardEnabled = "prompts_card_opted_in" + case promptRemindersEnabled = "prompts_reminders_opted_in" + case reminderDays = "reminders_days" + case reminderTime = "reminders_time" + case isPotentialBloggingSite = "is_potential_blogging_site" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(promptRemindersEnabled, forKey: .promptRemindersEnabled) + try container.encode(reminderDays, forKey: .reminderDays) + try container.encode(reminderTime, forKey: .reminderTime) + } + + public init(promptCardEnabled: Bool = false, promptRemindersEnabled: Bool, reminderDays: RemoteBloggingPromptsSettings.ReminderDays, reminderTime: String, isPotentialBloggingSite: Bool = false) { + self.promptCardEnabled = promptCardEnabled + self.promptRemindersEnabled = promptRemindersEnabled + self.reminderDays = reminderDays + self.reminderTime = reminderTime + self.isPotentialBloggingSite = isPotentialBloggingSite + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteComment.h b/WordPressKit/Sources/WordPressKit/Models/RemoteComment.h new file mode 100644 index 000000000000..82c1917c7750 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteComment.h @@ -0,0 +1,23 @@ +#import + +@interface RemoteComment : NSObject +@property (nonatomic, strong) NSNumber *commentID; +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorUrl; +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorIP; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *rawContent; +@property (nonatomic, strong) NSDate *date; +@property (nonatomic, strong) NSString *link; +@property (nonatomic, strong) NSNumber *parentID; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSString *postTitle; +@property (nonatomic, strong) NSString *status; +@property (nonatomic, strong) NSString *type; +@property (nonatomic) BOOL isLiked; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic) BOOL canModerate; +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteComment.m b/WordPressKit/Sources/WordPressKit/Models/RemoteComment.m new file mode 100644 index 000000000000..7620e8ae1469 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteComment.m @@ -0,0 +1,5 @@ +#import "RemoteComment.h" + +@implementation RemoteComment + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteCommentV2.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteCommentV2.swift new file mode 100644 index 000000000000..a80eaea2dbec --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteCommentV2.swift @@ -0,0 +1,95 @@ +/// Captures the JSON structure for Comments returned from API v2 endpoint. +public struct RemoteCommentV2 { + public var commentID: Int + public var postID: Int + public var parentID: Int = 0 + public var authorID: Int + public var authorName: String? + public var authorEmail: String? // only available in edit context + public var authorURL: String? + public var authorIP: String? // only available in edit context + public var authorUserAgent: String? // only available in edit context + public var authorAvatarURL: String? + public var date: Date + public var content: String + public var rawContent: String? // only available in edit context + public var link: String + public var status: String + public var type: String +} + +// MARK: - Decodable + +extension RemoteCommentV2: Decodable { + enum CodingKeys: String, CodingKey { + case id + case post + case parent + case author + case authorName = "author_name" + case authorEmail = "author_email" + case authorURL = "author_url" + case authorIP = "author_ip" + case authorUserAgent = "author_user_agent" + case date = "date_gmt" // take the gmt version, as the other `date` parameter doesn't provide timezone information. + case content + case authorAvatarURLs = "author_avatar_urls" + case link + case status + case type + } + + enum ContentCodingKeys: String, CodingKey { + case rendered + case raw + } + + enum AuthorAvatarCodingKeys: String, CodingKey { + case size96 = "96" // this is the default size for avatar URL in API v1.1. + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.commentID = try container.decode(Int.self, forKey: .id) + self.postID = try container.decode(Int.self, forKey: .post) + self.parentID = try container.decode(Int.self, forKey: .parent) + self.authorID = try container.decode(Int.self, forKey: .author) + self.authorName = try container.decode(String.self, forKey: .authorName) + self.authorEmail = try container.decodeIfPresent(String.self, forKey: .authorEmail) + self.authorURL = try container.decode(String.self, forKey: .authorURL) + self.authorIP = try container.decodeIfPresent(String.self, forKey: .authorIP) + self.authorUserAgent = try container.decodeIfPresent(String.self, forKey: .authorUserAgent) + self.link = try container.decode(String.self, forKey: .link) + self.type = try container.decode(String.self, forKey: .type) + + // since `date_gmt` is already in GMT timezone, manually add the timezone string to make the rfc3339 formatter happy (or it will throw otherwise). + guard let dateString = try? container.decode(String.self, forKey: .date), + let date = NSDate.with(wordPressComJSONString: dateString + "+00:00") else { + throw DecodingError.dataCorruptedError(forKey: .date, in: container, debugDescription: "Date parsing failed") + } + self.date = date + + let contentContainer = try container.nestedContainer(keyedBy: ContentCodingKeys.self, forKey: .content) + self.rawContent = try contentContainer.decodeIfPresent(String.self, forKey: .raw) + self.content = try contentContainer.decode(String.self, forKey: .rendered) + + let remoteStatus = try container.decode(String.self, forKey: .status) + self.status = Self.status(from: remoteStatus) + + let avatarContainer = try container.nestedContainer(keyedBy: AuthorAvatarCodingKeys.self, forKey: .authorAvatarURLs) + self.authorAvatarURL = try avatarContainer.decode(String.self, forKey: .size96) + } + + /// Maintain parity with the client-side comment statuses. Refer to `CommentServiceRemoteREST:statusWithRemoteStatus`. + private static func status(from remoteStatus: String) -> String { + switch remoteStatus { + case "unapproved": + return "hold" + case "approved": + return "approve" + default: + return remoteStatus + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteDomain.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteDomain.swift new file mode 100644 index 000000000000..d1dd5a6e9e11 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteDomain.swift @@ -0,0 +1,83 @@ +import Foundation + +@objc public enum DomainType: Int16 { + case registered + case mapped + case siteRedirect + case transfer + case wpCom + + public var description: String { + switch self { + case .registered: + return NSLocalizedString("Registered Domain", comment: "Describes a domain that was registered with WordPress.com") + case .mapped: + return NSLocalizedString("Mapped Domain", comment: "Describes a domain that was mapped to WordPress.com, but registered elsewhere") + case .siteRedirect: + return NSLocalizedString("Site Redirect", comment: "Describes a site redirect domain") + case .wpCom: + return NSLocalizedString("Included with Site", comment: "Describes a standard *.wordpress.com site domain") + case .transfer: + return NSLocalizedString("Transferred Domain", comment: "Describes a domain that was transferred from elsewhere to wordpress.com") + } + } + + init(domainJson: [String: Any]) { + self.init( + type: domainJson["domain"] as? String, + wpComDomain: domainJson["wpcom_domain"] as? Bool, + hasRegistration: domainJson["has_registration"] as? Bool + ) + } + + init(type: String?, wpComDomain: Bool?, hasRegistration: Bool?) { + if type == "redirect" { + self = .siteRedirect + } else if type == "transfer" { + self = .transfer + } else if wpComDomain == true { + self = .wpCom + } else if hasRegistration == true { + self = .registered + } else { + self = .mapped + } + } +} + +public struct RemoteDomain { + public let domainName: String + public let isPrimaryDomain: Bool + public let domainType: DomainType + + // Renewals / Expiry + public let autoRenewing: Bool + public let autoRenewalDate: String + public let expirySoon: Bool + public let expired: Bool + public let expiryDate: String + + public init(domainName: String, + isPrimaryDomain: Bool, + domainType: DomainType, + autoRenewing: Bool? = nil, + autoRenewalDate: String? = nil, + expirySoon: Bool? = nil, + expired: Bool? = nil, + expiryDate: String? = nil) { + self.domainName = domainName + self.isPrimaryDomain = isPrimaryDomain + self.domainType = domainType + self.autoRenewing = autoRenewing ?? false + self.autoRenewalDate = autoRenewalDate ?? "" + self.expirySoon = expirySoon ?? false + self.expired = expired ?? false + self.expiryDate = expiryDate ?? "" + } +} + +extension RemoteDomain: CustomStringConvertible { + public var description: String { + return "\(domainName) (\(domainType.description))" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteGravatarProfile.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteGravatarProfile.swift new file mode 100644 index 000000000000..f55ddb12bca3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteGravatarProfile.swift @@ -0,0 +1,34 @@ +import Foundation + +public class RemoteGravatarProfile { + public let profileID: String + public let hash: String + public let requestHash: String + public let profileUrl: String + public let preferredUsername: String + public let thumbnailUrl: String + public let name: String + public let displayName: String + public let formattedName: String + public let aboutMe: String + public let currentLocation: String + + init(dictionary: NSDictionary) { + profileID = dictionary.string(forKey: "id") ?? "" + hash = dictionary.string(forKey: "hash") ?? "" + requestHash = dictionary.string(forKey: "requestHash") ?? "" + profileUrl = dictionary.string(forKey: "profileUrl") ?? "" + preferredUsername = dictionary.string(forKey: "preferredUsername") ?? "" + thumbnailUrl = dictionary.string(forKey: "thumbnailUrl") ?? "" + name = dictionary.string(forKey: "name") ?? "" + displayName = dictionary.string(forKey: "displayName") ?? "" + + if let nameDictionary = dictionary.value(forKey: "name") as? NSDictionary { + formattedName = nameDictionary.string(forKey: "formatted") ?? "" + } else { + formattedName = "" + } + aboutMe = dictionary.string(forKey: "aboutMe") ?? "" + currentLocation = dictionary.string(forKey: "currentLocation") ?? "" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteHomepageType.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteHomepageType.swift new file mode 100644 index 000000000000..c6d7d4acc2c3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteHomepageType.swift @@ -0,0 +1,12 @@ +import Foundation + +/// The type of homepage used by a site: blog posts (.posts), or static pages (.page). +public enum RemoteHomepageType { + case page + case posts + + /// True if the site uses a page for its front page, rather than blog posts + internal var isPageOnFront: Bool { + return self == .page + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteInviteLink.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteInviteLink.swift new file mode 100644 index 000000000000..0f7d8a512887 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteInviteLink.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct RemoteInviteLink { + public let inviteKey: String + public let role: String + public let isPending: Bool + public let inviteDate: Date + public let groupInvite: Bool + public let expiry: Int64 + public let link: String + + init(dict: [String: Any]) { + var date = Date() + if let inviteDate = dict["invite_date"] as? String, + let formattedDate = ISO8601DateFormatter().date(from: inviteDate) { + date = formattedDate + } + inviteKey = dict["invite_key"] as? String ?? "" + role = dict["role"] as? String ?? "" + isPending = dict["is_pending"] as? Bool ?? false + inviteDate = date + groupInvite = dict["is_group_invite"] as? Bool ?? false + expiry = dict["expiry"] as? Int64 ?? 0 + link = dict["link"] as? String ?? "" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.h b/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.h new file mode 100644 index 000000000000..a5f02f317c5d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.h @@ -0,0 +1,28 @@ +#import + +@interface RemoteMedia : NSObject + +@property (nonatomic, strong, nullable) NSNumber *mediaID; +@property (nonatomic, strong, nullable) NSURL *url; +@property (nonatomic, strong, nullable) NSURL *localURL; +@property (nonatomic, strong, nullable) NSURL *largeURL; +@property (nonatomic, strong, nullable) NSURL *mediumURL; +@property (nonatomic, strong, nullable) NSURL *guid; +@property (nonatomic, strong, nullable) NSDate *date; +@property (nonatomic, strong, nullable) NSNumber *postID; +@property (nonatomic, strong, nullable) NSString *file; +@property (nonatomic, strong, nullable) NSString *mimeType; +@property (nonatomic, strong, nullable) NSString *extension; +@property (nonatomic, strong, nullable) NSString *title; +@property (nonatomic, strong, nullable) NSString *caption; +@property (nonatomic, strong, nullable) NSString *descriptionText; +@property (nonatomic, strong, nullable) NSString *alt; +@property (nonatomic, strong, nullable) NSNumber *height; +@property (nonatomic, strong, nullable) NSNumber *width; +@property (nonatomic, strong, nullable) NSString *shortcode; +@property (nonatomic, strong, nullable) NSDictionary *exif; +@property (nonatomic, strong, nullable) NSString *videopressGUID; +@property (nonatomic, strong, nullable) NSNumber *length; +@property (nonatomic, strong, nullable) NSString *remoteThumbnailURL; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.m b/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.m new file mode 100644 index 000000000000..006bc3241819 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteMedia.m @@ -0,0 +1,30 @@ +#import "RemoteMedia.h" +#import + +@implementation RemoteMedia + +- (NSString *)debugDescription { + NSDictionary *properties = [self debugProperties]; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSDictionary *)debugProperties { + unsigned int propertyCount; + objc_property_t *properties = class_copyPropertyList([RemoteMedia class], &propertyCount); + NSMutableDictionary *debugProperties = [NSMutableDictionary dictionaryWithCapacity:propertyCount]; + for (int i = 0; i < propertyCount; i++) + { + // Add property name to array + objc_property_t property = properties[i]; + const char *propertyName = property_getName(property); + id value = [self valueForKey:@(propertyName)]; + if (value == nil) { + value = [NSNull null]; + } + [debugProperties setObject:value forKey:@(propertyName)]; + } + free(properties); + return [NSDictionary dictionaryWithDictionary:debugProperties]; +} + +@end \ No newline at end of file diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteMenu.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteMenu.swift new file mode 100644 index 000000000000..203533e95dab --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteMenu.swift @@ -0,0 +1,11 @@ +import Foundation + +@objcMembers public class RemoteMenu: NSObject { + + public var menuID: NSNumber? + public var details: String? + public var name: String? + public var items: [RemoteMenuItem]? + public var locationNames: [String]? + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteMenuItem.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteMenuItem.swift new file mode 100644 index 000000000000..99abc029f31b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteMenuItem.swift @@ -0,0 +1,19 @@ +import Foundation + +@objcMembers public class RemoteMenuItem: NSObject { + + public var itemID: NSNumber? + public var contentID: NSNumber? + public var details: String? + public var linkTarget: String? + public var linkTitle: String? + public var name: String? + public var type: String? + public var typeFamily: String? + public var typeLabel: String? + public var urlStr: String? + public var classes: [String]? + public var children: [RemoteMenuItem]? + public weak var parentItem: RemoteMenuItem? + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteMenuLocation.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteMenuLocation.swift new file mode 100644 index 000000000000..f0d2978033b3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteMenuLocation.swift @@ -0,0 +1,9 @@ +import Foundation + +@objcMembers public class RemoteMenuLocation: NSObject { + + public var name: String? + public var defaultState: String? + public var details: String? + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteNotification.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteNotification.swift new file mode 100644 index 000000000000..689235732c74 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteNotification.swift @@ -0,0 +1,81 @@ +import Foundation +import WordPressShared + +// MARK: - RemoteNotification +// +public struct RemoteNotification { + /// Notification's Primary Key + /// + public let notificationId: String + + /// Notification's Hash + /// + public let notificationHash: String + + /// Indicates whether the note was already read, or not + /// + public let read: Bool + + /// Associated Resource's Icon, as a plain string + /// + public let icon: String? + + /// Noticon resource, associated with this notification + /// + public let noticon: String? + + /// Timestamp as a String + /// + public let timestamp: String? + + /// Notification Type + /// + public let type: String? + + /// Associated Resource's URL + /// + public let url: String? + + /// Plain Title ("1 Like" / Etc) + /// + public let title: String? + + /// Raw Subject Blocks + /// + public let subject: [AnyObject]? + + /// Raw Header Blocks + /// + public let header: [AnyObject]? + + /// Raw Body Blocks + /// + public let body: [AnyObject]? + + /// Raw Associated Metadata + /// + public let meta: [String: AnyObject]? + + /// Designed Initializer + /// + public init?(document: [String: AnyObject]) { + guard let noteId = document.valueAsString(forKey: "id"), + let noteHash = document.valueAsString(forKey: "note_hash") else { + return nil + } + + notificationId = noteId + notificationHash = noteHash + read = document["read"] as? Bool ?? false + icon = document["icon"] as? String + noticon = document["noticon"] as? String + timestamp = document["timestamp"] as? String + type = document["type"] as? String + url = document["url"] as? String + title = document["title"] as? String + subject = document["subject"] as? [AnyObject] + header = document["header"] as? [AnyObject] + body = document["body"] as? [AnyObject] + meta = document["meta"] as? [String: AnyObject] + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteNotificationSettings.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteNotificationSettings.swift new file mode 100644 index 000000000000..1c8cba2e1bd4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteNotificationSettings.swift @@ -0,0 +1,186 @@ +import Foundation + +/// The goal of this class is to parse Notification Settings data from the backend, and structure it in a +/// meaningful way. Notification Settings come in three different flavors: +/// +/// - "Our Own" Blog Settings +/// - "Third Party" Site Settings +/// - WordPress.com Settings +/// +/// Each one of the possible channels may post notifications via different streams: +/// Email, Push Notifications, and Timeline. +/// +open class RemoteNotificationSettings { + /// Represents the Channel to which the current settings are associated. + /// + public let channel: Channel + + /// Contains an array of the available Notification Streams. + /// + public let streams: [Stream] + + /// Represents a communication channel that may post notifications to the user. + /// + public enum Channel: Equatable { + case blog(blogId: Int) + case other + case wordPressCom + } + + /// Contains the Notification Settings for a specific communications stream. + /// + open class Stream { + open var kind: Kind + open var preferences: [String: Bool]? + + /// Enumerates all of the possible Stream Kinds + /// + public enum Kind: String { + case Timeline = "timeline" + case Email = "email" + case Device = "device" + + static let allValues = [ Timeline, Email, Device ] + } + + /// Private Designated Initializer + /// + /// - Parameters: + /// - kind: The Kind of stream we're currently dealing with + /// - preferences: Raw remote preferences, retrieved from the backend + /// + fileprivate init(kind: Kind, preferences: NSDictionary?) { + self.kind = kind + self.preferences = filterNonBooleanEntries(preferences) + } + + /// Helper method that will filter out non boolean entries, and return a native Swift collection. + /// + /// - Parameter dictionary: NextStep Dictionary containing raw values + /// + /// - Returns: A native Swift dictionary, containing only the Boolean entries + /// + private func filterNonBooleanEntries(_ dictionary: NSDictionary?) -> [String: Bool] { + var filtered = [String: Bool]() + if dictionary == nil { + return filtered + } + + for (key, value) in dictionary! { + if let stringKey = key as? String, + let boolValue = value as? Bool { + let value = value as AnyObject + // NSNumbers might get converted to Bool anyways + if value === kCFBooleanFalse || value === kCFBooleanTrue { + filtered[stringKey] = boolValue + } + } + } + + return filtered + } + + /// Parser method that will convert a raw dictionary of stream settings into Swift Native objects. + /// + /// - Parameter dictionary: NextStep Dictionary containing raw Stream Preferences + /// + /// - Returns: A native Swift array containing Stream entities + /// + fileprivate static func fromDictionary(_ dictionary: NSDictionary?) -> [Stream] { + var parsed = [Stream]() + + for kind in Kind.allValues { + if let preferences = dictionary?[kind.rawValue] as? NSDictionary { + parsed.append(Stream(kind: kind, preferences: preferences)) + } + } + + return parsed + } + } + + /// Private Designated Initializer + /// + /// - Parameters: + /// - channel: The communications channel that uses the current settings + /// - settings: Raw dictionary containing the remote settings response + /// + private init(channel: Channel, settings: NSDictionary?) { + self.channel = channel + self.streams = Stream.fromDictionary(settings) + } + + /// Private Designated Initializer + /// + /// - Parameter wpcomSettings: Dictionary containing the collection of WordPress.com Settings + /// + private init(wpcomSettings: NSDictionary?) { + // WordPress.com is a special scenario: It contains just one (unspecified) stream: Email + self.channel = Channel.wordPressCom + self.streams = [ Stream(kind: .Email, preferences: wpcomSettings) ] + } + + /// Private Convenience Initializer + /// + /// - Parameter blogSettings: Dictionary containing the collection of settings for a single blog + /// + private convenience init(blogSettings: NSDictionary?) { + let blogId = blogSettings?["blog_id"] as? Int ?? Int.max + self.init(channel: Channel.blog(blogId: blogId), settings: blogSettings) + } + + /// Private Convenience Initializer + /// + /// - Parameter otherSettings: Dictionary containing the collection of "Other Settings" + /// + private convenience init(otherSettings: NSDictionary?) { + self.init(channel: Channel.other, settings: otherSettings) + } + + /// Static Helper that will parse all of the Remote Settings, into a collection of Swift Native + /// RemoteNotificationSettings objects. + /// + /// - Parameter dictionary: Dictionary containing the remote Settings response + /// + /// - Returns: An array of RemoteNotificationSettings objects + /// + public static func fromDictionary(_ dictionary: NSDictionary?) -> [RemoteNotificationSettings] { + var parsed = [RemoteNotificationSettings]() + + if let rawBlogs = dictionary?["blogs"] as? [NSDictionary] { + for rawBlog in rawBlogs { + let parsedBlog = RemoteNotificationSettings(blogSettings: rawBlog) + parsed.append(parsedBlog) + } + } + + let other = RemoteNotificationSettings(otherSettings: dictionary?["other"] as? NSDictionary) + parsed.append(other) + + let wpcom = RemoteNotificationSettings(wpcomSettings: dictionary?["wpcom"] as? NSDictionary) + parsed.append(wpcom) + + return parsed + } +} + +/// Swift requires this method to be implemented globally. Sorry about that! +/// +/// - Parameters: +/// - lhs: Left Hand Side Channel +/// - rhs: Right Hand Side Channel +/// +/// - Returns: A boolean indicating whether two channels are equal. Or not! +/// +public func ==(lhs: RemoteNotificationSettings.Channel, rhs: RemoteNotificationSettings.Channel) -> Bool { + switch (lhs, rhs) { + case (let .blog(firstBlogId), let .blog(secondBlogId)) where firstBlogId == secondBlogId: + return true + case (.other, .other): + return true + case (.wordPressCom, .wordPressCom): + return true + default: + return false + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePageLayouts.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePageLayouts.swift new file mode 100644 index 000000000000..42fcb9f9b827 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePageLayouts.swift @@ -0,0 +1,86 @@ +import Foundation + +public struct RemotePageLayouts: Codable { + public let layouts: [RemoteLayout] + public let categories: [RemoteLayoutCategory] + + enum CodingKeys: String, CodingKey { + case layouts + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + layouts = try map.decode([RemoteLayout].self, forKey: .layouts) + categories = try map.decode([RemoteLayoutCategory].self, forKey: .categories) + } + + public init() { + self.init(layouts: [], categories: []) + } + + public init(layouts: [RemoteLayout], categories: [RemoteLayoutCategory]) { + self.layouts = layouts + self.categories = categories + } +} + +public struct RemoteLayout: Codable { + public let slug: String + public let title: String + public let preview: String? + public let previewTablet: String? + public let previewMobile: String? + public let demoUrl: String? + public let content: String? + public let categories: [RemoteLayoutCategory] + + enum CodingKeys: String, CodingKey { + case slug + case title + case preview + case previewTablet = "preview_tablet" + case previewMobile = "preview_mobile" + case demoUrl = "demo_url" + case content + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + preview = try? map.decode(String.self, forKey: .preview) + previewTablet = try? map.decode(String.self, forKey: .previewTablet) + previewMobile = try? map.decode(String.self, forKey: .previewMobile) + demoUrl = try? map.decode(String.self, forKey: .demoUrl) + content = try? map.decode(String.self, forKey: .content) + categories = try map.decode([RemoteLayoutCategory].self, forKey: .categories) + } +} + +public struct RemoteLayoutCategory: Codable, Comparable { + public static func < (lhs: RemoteLayoutCategory, rhs: RemoteLayoutCategory) -> Bool { + return lhs.slug < rhs.slug + } + + public let slug: String + public let title: String + public let description: String + public let emoji: String? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case emoji + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + description = try map.decode(String.self, forKey: .description) + emoji = try? map.decode(String.self, forKey: .emoji) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePerson.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePerson.swift new file mode 100644 index 000000000000..3ce0c80e5de7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePerson.swift @@ -0,0 +1,261 @@ +import Foundation +import WordPressShared + +// MARK: - Defines all of the peroperties a Person may have +// +public protocol RemotePerson { + /// Properties + /// + var ID: Int { get } + var username: String { get } + var firstName: String? { get } + var lastName: String? { get } + var displayName: String { get } + var role: String { get } + var siteID: Int { get } + var linkedUserID: Int { get } + var avatarURL: URL? { get } + var isSuperAdmin: Bool { get } + var fullName: String { get } + + /// Static Properties + /// + static var kind: PersonKind { get } + + /// Initializers + /// + init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) +} + +// MARK: - Specifies all of the Roles a Person may have +// +public struct RemoteRole { + public let slug: String + public let name: String + + public init(slug: String, name: String) { + self.slug = slug + self.name = name + } +} + +extension RemoteRole { + public static let viewer = RemoteRole(slug: "follower", name: NSLocalizedString("Viewer", comment: "User role badge")) + public static let follower = RemoteRole(slug: "follower", name: NSLocalizedString("Follower", comment: "User role badge")) +} + +// MARK: - Specifies all of the possible Person Types that might exist. +// +public enum PersonKind: Int { + case user + case follower + case viewer + case emailFollower +} + +// MARK: - Defines a Blog's User +// +public struct User: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.user + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Follower +// +public struct Follower: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.follower + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Viewer +// +public struct Viewer: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.viewer + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } +} + +// MARK: - Defines a Blog's Email Follower +// +public struct EmailFollower: RemotePerson { + public let ID: Int + public let username: String + public let firstName: String? + public let lastName: String? + public let displayName: String + public let role: String + public let siteID: Int + public let linkedUserID: Int + public let avatarURL: URL? + public let isSuperAdmin: Bool + public static let kind = PersonKind.emailFollower + + public init(ID: Int, + username: String, + firstName: String?, + lastName: String?, + displayName: String, + role: String, + siteID: Int, + linkedUserID: Int, + avatarURL: URL?, + isSuperAdmin: Bool) { + self.ID = ID + self.username = username + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.role = role + self.siteID = siteID + self.linkedUserID = linkedUserID + self.avatarURL = avatarURL + self.isSuperAdmin = isSuperAdmin + } + + public init?(siteID: Int, statsFollower: StatsFollower?) { + guard let statsFollower = statsFollower, + let stringId = statsFollower.id, + let id = Int(stringId) else { + return nil + } + + self.ID = id + self.username = "" + self.firstName = nil + self.lastName = nil + self.displayName = statsFollower.name + self.role = "" + self.siteID = siteID + self.linkedUserID = id + self.avatarURL = statsFollower.avatarURL + self.isSuperAdmin = false + } +} +// MARK: - Extensions +// +public extension RemotePerson { + var fullName: String { + let first = firstName ?? String() + let last = lastName ?? String() + let separator = (first.isEmpty == false && last.isEmpty == false) ? " " : "" + + return "\(first)\(separator)\(last)" + } +} + +// MARK: - Operator Overloading +// +public func ==(lhs: T, rhs: T) -> Bool { + return lhs.ID == rhs.ID + && lhs.username == rhs.username + && lhs.firstName == rhs.firstName + && lhs.lastName == rhs.lastName + && lhs.displayName == rhs.displayName + && lhs.role == rhs.role + && lhs.siteID == rhs.siteID + && lhs.linkedUserID == rhs.linkedUserID + && lhs.avatarURL == rhs.avatarURL + && lhs.isSuperAdmin == rhs.isSuperAdmin + && type(of: lhs) == type(of: rhs) +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePlan_ApiVersion1_3.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePlan_ApiVersion1_3.swift new file mode 100644 index 000000000000..882bc6a6809c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePlan_ApiVersion1_3.swift @@ -0,0 +1,44 @@ +import Foundation + +/// This is for getPlansForSite service in api version v1.3. +/// There are some huge differences between v1.3 and v1.2 so a new +/// class is created for v1.3. +@objc public class RemotePlan_ApiVersion1_3: NSObject, Codable { + public var autoRenew: Bool? + public var freeTrial: Bool? + public var interval: Int? + public var rawDiscount: Double? + public var rawPrice: Double? + public var hasDomainCredit: Bool? + public var currentPlan: Bool? + public var userIsOwner: Bool? + public var isDomainUpgrade: Bool? + @objc public var autoRenewDate: Date? + @objc public var currencyCode: String? + @objc public var discountReason: String? + @objc public var expiry: Date? + @objc public var formattedDiscount: String? + @objc public var formattedOriginalPrice: String? + @objc public var formattedPrice: String? + @objc public var planID: String? + @objc public var productName: String? + @objc public var productSlug: String? + @objc public var subscribedDate: Date? + @objc public var userFacingExpiry: Date? + + @objc public var isAutoRenew: Bool { + return autoRenew ?? false + } + + @objc public var isCurrentPlan: Bool { + return currentPlan ?? false + } + + @objc public var isFreeTrial: Bool { + return freeTrial ?? false + } + + @objc public var doesHaveDomainCredit: Bool { + return hasDomainCredit ?? false + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePost.h b/WordPressKit/Sources/WordPressKit/Models/RemotePost.h new file mode 100644 index 000000000000..7a06fc5cab39 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePost.h @@ -0,0 +1,66 @@ +#import +@class RemotePostAutosave; + +extern NSString * const PostStatusDraft; +extern NSString * const PostStatusPending; +extern NSString * const PostStatusPrivate; +extern NSString * const PostStatusPublish; +extern NSString * const PostStatusScheduled; +extern NSString * const PostStatusTrash; +extern NSString * const PostStatusDeleted; + +/// Represents the response object for APIs that create or update posts. +@interface RemotePost : NSObject +- (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content; + +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSNumber *siteID; +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorDisplayName; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSDate *date; +@property (nonatomic, strong) NSDate *dateModified; +@property (nonatomic, strong) NSString *title; +@property (nonatomic, strong) NSURL *URL; +@property (nonatomic, strong) NSURL *shortURL; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *excerpt; +@property (nonatomic, strong) NSString *slug; +@property (nonatomic, strong) NSString *suggestedSlug; +@property (nonatomic, strong) NSString *status; +@property (nonatomic, strong) NSString *password; +@property (nonatomic, strong) NSNumber *parentID; +@property (nonatomic, strong) NSNumber *postThumbnailID; +@property (nonatomic, strong) NSString *postThumbnailPath; +@property (nonatomic, strong) NSString *type; +@property (nonatomic, strong) NSString *format; + +/** +* A snapshot of the post at the last autosave. +* +* This is nullable. +*/ +@property (nonatomic, strong) RemotePostAutosave *autosave; + +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic, strong) NSNumber *likeCount; + +@property (nonatomic, strong) NSArray *categories; +@property (nonatomic, strong) NSArray *revisions; +@property (nonatomic, strong) NSArray *tags; +@property (nonatomic, strong) NSString *pathForDisplayImage; +@property (nonatomic, assign) NSNumber *isStickyPost; +@property (nonatomic, assign) BOOL isFeaturedImageChanged; + +/** + Array of custom fields. Each value is a dictionary containing {ID, key, value} + */ +@property (nonatomic, strong) NSArray *metadata; + +// Featured images? +// Geolocation? +// Attachments? +// Metadata? +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePost.m b/WordPressKit/Sources/WordPressKit/Models/RemotePost.m new file mode 100644 index 000000000000..ef7960fef890 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePost.m @@ -0,0 +1,50 @@ +#import "RemotePost.h" +#import + +NSString * const PostStatusDraft = @"draft"; +NSString * const PostStatusPending = @"pending"; +NSString * const PostStatusPrivate = @"private"; +NSString * const PostStatusPublish = @"publish"; +NSString * const PostStatusScheduled = @"future"; +NSString * const PostStatusTrash = @"trash"; +NSString * const PostStatusDeleted = @"deleted"; // Returned by wpcom REST API when a post is permanently deleted. + +@implementation RemotePost + +- (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content +{ + self = [super init]; + if (self) { + _siteID = siteID; + _status = status; + _title = title; + _content = content; + } + return self; +} + +- (NSString *)debugDescription { + NSDictionary *properties = [self debugProperties]; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSDictionary *)debugProperties { + unsigned int propertyCount; + objc_property_t *properties = class_copyPropertyList([RemotePost class], &propertyCount); + NSMutableDictionary *debugProperties = [NSMutableDictionary dictionaryWithCapacity:propertyCount]; + for (int i = 0; i < propertyCount; i++) + { + // Add property name to array + objc_property_t property = properties[i]; + const char *propertyName = property_getName(property); + id value = [self valueForKey:@(propertyName)]; + if (value == nil) { + value = [NSNull null]; + } + [debugProperties setObject:value forKey:@(propertyName)]; + } + free(properties); + return [NSDictionary dictionaryWithDictionary:debugProperties]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostAutosave.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePostAutosave.swift new file mode 100644 index 000000000000..fcc7c382e6b9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostAutosave.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Encapsulates the autosave attributes of a post. +@objc +@objcMembers +public class RemotePostAutosave: NSObject { + public var title: String? + public var excerpt: String? + public var content: String? + public var modifiedDate: Date? + public var identifier: NSNumber? + public var authorID: String? + public var postID: NSNumber? + public var previewURL: String? +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.h b/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.h new file mode 100644 index 000000000000..21f189e98e70 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.h @@ -0,0 +1,7 @@ +#import + +@interface RemotePostCategory : NSObject +@property (nonatomic, strong) NSNumber *categoryID; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSNumber *parentID; +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.m b/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.m new file mode 100644 index 000000000000..235fd1d8fdb8 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostCategory.m @@ -0,0 +1,18 @@ +#import "RemotePostCategory.h" + +@implementation RemotePostCategory + +- (NSString *)debugDescription { + NSDictionary *properties = @{ + @"ID": self.categoryID, + @"name": self.name, + @"parent": self.parentID, + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description { + return [NSString stringWithFormat:@"<%@: %p> %@[%@]", NSStringFromClass([self class]), self, self.name, self.categoryID]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostParameters.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePostParameters.swift new file mode 100644 index 000000000000..a89a1b17694a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostParameters.swift @@ -0,0 +1,448 @@ +import Foundation + +/// The parameters required to create a post or a page. +public struct RemotePostCreateParameters: Equatable { + public var type: String + + public var status: String + public var date: Date? + public var authorID: Int? + public var title: String? + public var content: String? + public var password: String? + public var excerpt: String? + public var slug: String? + public var featuredImageID: Int? + + // Pages + public var parentPageID: Int? + + // Posts + public var format: String? + public var isSticky = false + public var tags: [String] = [] + public var categoryIDs: [Int] = [] + public var metadata: Set = [] + + public init(type: String, status: String) { + self.type = type + self.status = status + } +} + +/// Represents a partial update to be applied to a post or a page. +public struct RemotePostUpdateParameters: Equatable { + public var ifNotModifiedSince: Date? + + public var status: String? + public var date: Date? + public var authorID: Int? + public var title: String?? + public var content: String?? + public var password: String?? + public var excerpt: String?? + public var slug: String?? + public var featuredImageID: Int?? + + // Pages + public var parentPageID: Int?? + + // Posts + public var format: String?? + public var isSticky: Bool? + public var tags: [String]? + public var categoryIDs: [Int]? + public var metadata: Set? + + public init() {} +} + +public struct RemotePostMetadataItem: Hashable { + public var id: String? + public var key: String? + public var value: String? + + public init(id: String?, key: String?, value: String?) { + self.id = id + self.key = key + self.value = value + } +} + +// MARK: - Diff + +extension RemotePostCreateParameters { + /// Returns a diff required to update from the `previous` to the current + /// version of the post. + public func changes(from previous: RemotePostCreateParameters) -> RemotePostUpdateParameters { + var changes = RemotePostUpdateParameters() + if previous.status != status { + changes.status = status + } + if previous.date != date { + changes.date = date + } + if previous.authorID != authorID { + changes.authorID = authorID + } + if (previous.title ?? "") != (title ?? "") { + changes.title = (title ?? "") + } + if (previous.content ?? "") != (content ?? "") { + changes.content = (content ?? "") + } + if (previous.password ?? "") != (password ?? "") { + changes.password = password + } + if (previous.excerpt ?? "") != (excerpt ?? "") { + changes.excerpt = (excerpt ?? "") + } + if (previous.slug ?? "") != (slug ?? "") { + changes.slug = (slug ?? "") + } + if previous.featuredImageID != featuredImageID { + changes.featuredImageID = featuredImageID + } + if previous.parentPageID != parentPageID { + changes.parentPageID = parentPageID + } + if previous.format != format { + changes.format = format + } + if previous.isSticky != isSticky { + changes.isSticky = isSticky + } + if previous.tags != tags { + changes.tags = tags + } + if Set(previous.categoryIDs) != Set(categoryIDs) { + changes.categoryIDs = categoryIDs + } + if previous.metadata != metadata { + changes.metadata = metadata + } + return changes + } + + /// Applies the diff to the receiver. + public mutating func apply(_ changes: RemotePostUpdateParameters) { + if let status = changes.status { + self.status = status + } + if let date = changes.date { + self.date = date + } + if let authorID = changes.authorID { + self.authorID = authorID + } + if let title = changes.title { + self.title = title + } + if let content = changes.content { + self.content = content + } + if let password = changes.password { + self.password = password + } + if let excerpt = changes.excerpt { + self.excerpt = excerpt + } + if let slug = changes.slug { + self.slug = slug + } + if let featuredImageID = changes.featuredImageID { + self.featuredImageID = featuredImageID + } + if let parentPageID = changes.parentPageID { + self.parentPageID = parentPageID + } + if let format = changes.format { + self.format = format + } + if let isSticky = changes.isSticky { + self.isSticky = isSticky + } + if let tags = changes.tags { + self.tags = tags + } + if let categoryIDs = changes.categoryIDs { + self.categoryIDs = categoryIDs + } + if let metadata = changes.metadata { + self.metadata = metadata + } + } +} + +// MARK: - Encoding (WP.COM REST API) + +private enum RemotePostWordPressComCodingKeys: String, CodingKey { + case ifNotModifiedSince = "if_not_modified_since" + case type + case status + case date + case authorID = "author" + case title + case content + case password + case excerpt + case slug + case featuredImageID = "featured_image" + case parentPageID = "parent" + case terms + case format + case isSticky = "sticky" + case categoryIDs = "categories_by_id" + case metadata + + static let postTags = "post_tag" +} + +struct RemotePostCreateParametersWordPressComEncoder: Encodable { + let parameters: RemotePostCreateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostWordPressComCodingKeys.self) + try container.encodeIfPresent(parameters.type, forKey: .type) + try container.encodeIfPresent(parameters.status, forKey: .status) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeIfPresent(parameters.title, forKey: .title) + try container.encodeIfPresent(parameters.content, forKey: .content) + try container.encodeIfPresent(parameters.password, forKey: .password) + try container.encodeIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeIfPresent(parameters.slug, forKey: .slug) + try container.encodeIfPresent(parameters.featuredImageID, forKey: .featuredImageID) + if !parameters.metadata.isEmpty { + let metadata = parameters.metadata.map(RemotePostUpdateParametersWordPressComMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + try container.encodeIfPresent(parameters.parentPageID, forKey: .parentPageID) + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if !parameters.tags.isEmpty { + try container.encode([RemotePostWordPressComCodingKeys.postTags: parameters.tags], forKey: .terms) + } + if !parameters.categoryIDs.isEmpty { + try container.encodeIfPresent(parameters.categoryIDs, forKey: .categoryIDs) + } + if parameters.isSticky { + try container.encode(parameters.isSticky, forKey: .isSticky) + } + } + + // - warning: fixme + static func encodeMetadata(_ metadata: Set) -> [[String: Any]] { + metadata.map { item in + var operation = "update" + if item.key == nil { + if item.id != nil && item.value == nil { + operation = "delete" + } else if item.id == nil && item.value != nil { + operation = "add" + } + } + var dictionary: [String: Any] = [:] + if let id = item.id { dictionary["id"] = id } + if let value = item.value { dictionary["value"] = value } + if let key = item.key { dictionary["key"] = key } + dictionary["operation"] = operation + return dictionary + } + } +} + +struct RemotePostUpdateParametersWordPressComMetadata: Encodable { + let id: String? + let operation: String + let key: String? + let value: String? + + init(_ item: RemotePostMetadataItem) { + if item.key == nil { + if item.id != nil && item.value == nil { + self.operation = "delete" + } else if item.id == nil && item.value != nil { + self.operation = "add" + } else { + self.operation = "update" + } + } else { + self.operation = "update" + } + self.id = item.id + self.key = item.key + self.value = item.value + } +} + +struct RemotePostUpdateParametersWordPressComEncoder: Encodable { + let parameters: RemotePostUpdateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostWordPressComCodingKeys.self) + try container.encodeIfPresent(parameters.ifNotModifiedSince, forKey: .ifNotModifiedSince) + + try container.encodeIfPresent(parameters.status, forKey: .status) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeStringIfPresent(parameters.title, forKey: .title) + try container.encodeStringIfPresent(parameters.content, forKey: .content) + try container.encodeStringIfPresent(parameters.password, forKey: .password) + try container.encodeStringIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeStringIfPresent(parameters.slug, forKey: .slug) + if let value = parameters.featuredImageID { + try container.encodeNullableID(value, forKey: .featuredImageID) + } + if let metadata = parameters.metadata, !metadata.isEmpty { + let metadata = metadata.map(RemotePostUpdateParametersWordPressComMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + if let parentPageID = parameters.parentPageID { + try container.encodeNullableID(parentPageID, forKey: .parentPageID) + } + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if let tags = parameters.tags { + try container.encode([RemotePostWordPressComCodingKeys.postTags: tags], forKey: .terms) + } + try container.encodeIfPresent(parameters.categoryIDs, forKey: .categoryIDs) + try container.encodeIfPresent(parameters.isSticky, forKey: .isSticky) + } +} + +// MARK: - Encoding (XML-RPC) + +private enum RemotePostXMLRPCCodingKeys: String, CodingKey { + case ifNotModifiedSince = "if_not_modified_since" + case type = "post_type" + case postStatus = "post_status" + case date = "post_date" + case authorID = "post_author" + case title = "post_title" + case content = "post_content" + case password = "post_password" + case excerpt = "post_excerpt" + case slug = "post_name" + case featuredImageID = "post_thumbnail" + case parentPageID = "post_parent" + case terms = "terms" + case termNames = "terms_names" + case format = "post_format" + case isSticky = "sticky" + case metadata = "custom_fields" + + static let taxonomyTag = "post_tag" + static let taxonomyCategory = "category" +} + +struct RemotePostCreateParametersXMLRPCEncoder: Encodable { + let parameters: RemotePostCreateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostXMLRPCCodingKeys.self) + try container.encode(parameters.type, forKey: .type) + try container.encodeIfPresent(parameters.status, forKey: .postStatus) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeIfPresent(parameters.title, forKey: .title) + try container.encodeIfPresent(parameters.content, forKey: .content) + try container.encodeIfPresent(parameters.password, forKey: .password) + try container.encodeIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeIfPresent(parameters.slug, forKey: .slug) + try container.encodeIfPresent(parameters.featuredImageID, forKey: .featuredImageID) + if !parameters.metadata.isEmpty { + let metadata = parameters.metadata.map(RemotePostUpdateParametersXMLRPCMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + try container.encodeIfPresent(parameters.parentPageID, forKey: .parentPageID) + + // Posts + try container.encodeIfPresent(parameters.format, forKey: .format) + if !parameters.tags.isEmpty { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyTag: parameters.tags], forKey: .termNames) + } + if !parameters.categoryIDs.isEmpty { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyCategory: parameters.categoryIDs], forKey: .terms) + } + if parameters.isSticky { + try container.encode(parameters.isSticky, forKey: .isSticky) + } + } +} + +struct RemotePostUpdateParametersXMLRPCEncoder: Encodable { + let parameters: RemotePostUpdateParameters + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: RemotePostXMLRPCCodingKeys.self) + try container.encodeIfPresent(parameters.ifNotModifiedSince, forKey: .ifNotModifiedSince) + try container.encodeIfPresent(parameters.status, forKey: .postStatus) + try container.encodeIfPresent(parameters.date, forKey: .date) + try container.encodeIfPresent(parameters.authorID, forKey: .authorID) + try container.encodeStringIfPresent(parameters.title, forKey: .title) + try container.encodeStringIfPresent(parameters.content, forKey: .content) + try container.encodeStringIfPresent(parameters.password, forKey: .password) + try container.encodeStringIfPresent(parameters.excerpt, forKey: .excerpt) + try container.encodeStringIfPresent(parameters.slug, forKey: .slug) + if let value = parameters.featuredImageID { + try container.encodeNullableID(value, forKey: .featuredImageID) + } + if let metadata = parameters.metadata, !metadata.isEmpty { + let metadata = metadata.map(RemotePostUpdateParametersXMLRPCMetadata.init) + try container.encode(metadata, forKey: .metadata) + } + + // Pages + if let parentPageID = parameters.parentPageID { + try container.encodeNullableID(parentPageID, forKey: .parentPageID) + } + + // Posts + try container.encodeStringIfPresent(parameters.format, forKey: .format) + if let tags = parameters.tags { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyTag: tags], forKey: .termNames) + } + if let categoryIDs = parameters.categoryIDs { + try container.encode([RemotePostXMLRPCCodingKeys.taxonomyCategory: categoryIDs], forKey: .terms) + } + try container.encodeIfPresent(parameters.isSticky, forKey: .isSticky) + } +} + +private struct RemotePostUpdateParametersXMLRPCMetadata: Encodable { + let id: String? + let key: String? + let value: String? + + init(_ item: RemotePostMetadataItem) { + self.id = item.id + self.key = item.key + self.value = item.value + } +} + +private extension KeyedEncodingContainer { + mutating func encodeStringIfPresent(_ value: String??, forKey key: Key) throws { + guard let value else { return } + try encode(value ?? "", forKey: key) + } + + /// - note: Some IDs are passed as integers, but, to reset them, you need to pass + /// an empty string (passing `nil` does not work) + mutating func encodeNullableID(_ value: Int?, forKey key: Key) throws { + if let value { + try encode(value, forKey: key) + } else { + try encode("", forKey: key) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.h b/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.h new file mode 100644 index 000000000000..e6a84f5957e4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.h @@ -0,0 +1,11 @@ +#import + +@interface RemotePostTag : NSObject + +@property (nonatomic, strong) NSNumber *tagID; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSString *slug; +@property (nonatomic, strong) NSString *tagDescription; +@property (nonatomic, strong) NSNumber *postCount; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.m b/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.m new file mode 100644 index 000000000000..4f4466ac5d27 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostTag.m @@ -0,0 +1,19 @@ +#import "RemotePostTag.h" + +@implementation RemotePostTag + +- (NSString *)debugDescription +{ + NSDictionary *properties = @{ + @"ID": self.tagID, + @"name": self.name + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p> %@[%@]", NSStringFromClass([self class]), self, self.name, self.tagID]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostType.h b/WordPressKit/Sources/WordPressKit/Models/RemotePostType.h new file mode 100644 index 000000000000..895e91178cb2 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostType.h @@ -0,0 +1,9 @@ +#import + +@interface RemotePostType : NSObject + +@property (nonatomic, strong) NSNumber *apiQueryable; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, strong) NSString *label; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePostType.m b/WordPressKit/Sources/WordPressKit/Models/RemotePostType.m new file mode 100644 index 000000000000..4d1cc15c8334 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePostType.m @@ -0,0 +1,20 @@ +#import "RemotePostType.h" + +@implementation RemotePostType + +- (NSString *)debugDescription +{ + NSDictionary *properties = @{ + @"name": self.name, + @"label": self.label, + @"apiQueryable": self.apiQueryable + }; + return [NSString stringWithFormat:@"<%@: %p> (%@)", NSStringFromClass([self class]), self, properties]; +} + +- (NSString *)description +{ + return [NSString stringWithFormat:@"<%@: %p> %@[%@], apiQueryable=%@", NSStringFromClass([self class]), self, self.name, self.label, self.apiQueryable]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteProfile.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteProfile.swift new file mode 100644 index 000000000000..b89e48ef6ba3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteProfile.swift @@ -0,0 +1,28 @@ +import Foundation + +public class RemoteProfile { + public let bio: String + public let displayName: String + public let email: String + public let firstName: String + public let lastName: String + public let nicename: String + public let nickname: String + public let url: String + public let userID: Int + public let username: String + + public init(dictionary: NSDictionary) { + bio = dictionary.string(forKey: "bio") ?? "" + displayName = dictionary.string(forKey: "display_name") ?? "" + email = dictionary.string(forKey: "email") ?? "" + firstName = dictionary.string(forKey: "first_name") ?? "" + lastName = dictionary.string(forKey: "last_name") ?? "" + nicename = dictionary.string(forKey: "nicename") ?? "" + nickname = dictionary.string(forKey: "nickname") ?? "" + url = dictionary.string(forKey: "url") ?? "" + userID = dictionary.number(forKey: "user_id")?.intValue ?? 0 + username = dictionary.string(forKey: "username") ?? "" + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeConnection.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeConnection.swift new file mode 100644 index 000000000000..76b49f5c75e7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeConnection.swift @@ -0,0 +1,22 @@ +import Foundation + +@objc open class RemotePublicizeConnection: NSObject { + @objc open var connectionID: NSNumber = 0 + @objc open var dateIssued = Date() + @objc open var dateExpires: Date? + @objc open var externalID = "" + @objc open var externalName = "" + @objc open var externalDisplay = "" + @objc open var externalProfilePicture = "" + @objc open var externalProfileURL = "" + @objc open var externalFollowerCount: NSNumber = 0 + @objc open var keyringConnectionID: NSNumber = 0 + @objc open var keyringConnectionUserID: NSNumber = 0 + @objc open var label = "" + @objc open var refreshURL = "" + @objc open var service = "" + @objc open var shared = false + @objc open var status = "" + @objc open var siteID: NSNumber = 0 + @objc open var userID: NSNumber = 0 +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeInfo.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeInfo.swift new file mode 100644 index 000000000000..032fe371d042 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeInfo.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct RemotePublicizeInfo: Decodable { + public let shareLimit: Int + public let toBePublicizedCount: Int + public let sharedPostsCount: Int + public let sharesRemaining: Int + + private enum CodingKeys: CodingKey { + case shareLimit + case toBePublicizedCount + case sharedPostsCount + case sharesRemaining + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeService.swift b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeService.swift new file mode 100644 index 000000000000..2c0515642ec4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemotePublicizeService.swift @@ -0,0 +1,16 @@ +import Foundation + +@objc open class RemotePublicizeService: NSObject { + @objc open var connectURL = "" + @objc open var detail = "" + @objc open var externalUsersOnly = false + @objc open var icon = "" + @objc open var jetpackSupport = false + @objc open var jetpackModuleRequired = "" + @objc open var label = "" + @objc open var multipleExternalUserIDSupport = false + @objc open var order: NSNumber = 0 + @objc open var serviceID = "" + @objc open var type = "" + @objc open var status = "" +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCard.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCard.swift new file mode 100644 index 000000000000..f37376ef83e4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCard.swift @@ -0,0 +1,57 @@ +import Foundation + +struct ReaderCardEnvelope: Decodable { + var cards: [RemoteReaderCard] + var nextPageHandle: String? + + private enum CodingKeys: String, CodingKey { + case cards + case nextPageHandle = "next_page_handle" + } +} + +public struct RemoteReaderCard: Decodable { + public enum CardType: String { + case post + case interests = "interests_you_may_like" + case sites = "recommended_blogs" + case unknown + } + + public var type: CardType + public var post: RemoteReaderPost? + public var interests: [RemoteReaderInterest]? + public var sites: [RemoteReaderSiteInfo]? + + private enum CodingKeys: String, CodingKey { + case type + case data + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let typeString = try container.decode(String.self, forKey: .type) + type = CardType(rawValue: typeString) ?? .unknown + + switch type { + case .post: + let postDictionary = try container.decode([String: Any].self, forKey: .data) + post = RemoteReaderPost(dictionary: postDictionary) + case .interests: + interests = try container.decode([RemoteReaderInterest].self, forKey: .data) + case .sites: + let sitesArray = try container.decode([Any].self, forKey: .data) + + sites = sitesArray.compactMap { + guard let dict = $0 as? NSDictionary else { + return nil + } + + return RemoteReaderSiteInfo.siteInfo(forSiteResponse: dict, isFeed: false) + } + + default: + post = nil + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCrossPostMeta.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCrossPostMeta.swift new file mode 100644 index 000000000000..96c444fb25e9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderCrossPostMeta.swift @@ -0,0 +1,9 @@ +import Foundation + +open class RemoteReaderCrossPostMeta: NSObject { + @objc open var postID: NSNumber = 0 + @objc open var siteID: NSNumber = 0 + @objc open var siteURL = "" + @objc open var postURL = "" + @objc open var commentURL = "" +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderInterest.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderInterest.swift new file mode 100644 index 000000000000..51f8d6c7f4b4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderInterest.swift @@ -0,0 +1,21 @@ +import Foundation + +struct ReaderInterestEnvelope: Decodable { + var interests: [RemoteReaderInterest] +} + +public struct RemoteReaderInterest: Decodable { + public var title: String + public var slug: String + + private enum CodingKeys: String, CodingKey { + case title + case slug = "slug" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = try container.decode(String.self, forKey: .title) + slug = try container.decode(String.self, forKey: .slug) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.h b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.h new file mode 100644 index 000000000000..f25b51a71259 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.h @@ -0,0 +1,70 @@ +#import + +@class RemoteSourcePostAttribution; +@class RemoteReaderCrossPostMeta; + +@interface RemoteReaderPost : NSObject + +// Reader Post Model +@property (nonatomic, strong) NSString *authorAvatarURL; +@property (nonatomic, strong) NSString *authorDisplayName; +@property (nonatomic, strong) NSString *authorEmail; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSString *siteIconURL; +@property (nonatomic, strong) NSString *blogName; +@property (nonatomic, strong) NSString *blogDescription; +@property (nonatomic, strong) NSString *blogURL; +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic) BOOL commentsOpen; +@property (nonatomic, strong) NSString *featuredImage; +@property (nonatomic, strong) NSNumber *feedID; +@property (nonatomic, strong) NSNumber *feedItemID; +@property (nonatomic, strong) NSString *globalID; +@property (nonatomic, strong) NSNumber *organizationID; +@property (nonatomic) BOOL isBlogAtomic; +@property (nonatomic) BOOL isBlogPrivate; +@property (nonatomic) BOOL isFollowing; +@property (nonatomic) BOOL isLiked; +@property (nonatomic) BOOL isReblogged; +@property (nonatomic) BOOL isWPCom; +@property (nonatomic) BOOL isSeen; +@property (nonatomic) BOOL isSeenSupported; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic, strong) NSNumber *score; +@property (nonatomic, strong) NSNumber *siteID; +@property (nonatomic, strong) NSDate *sortDate; +@property (nonatomic, strong) NSNumber *sortRank; +@property (nonatomic, strong) NSString *summary; +@property (nonatomic, strong) NSString *tags; +@property (nonatomic) BOOL isLikesEnabled; +@property (nonatomic) BOOL isSharingEnabled; +@property (nonatomic, strong) RemoteSourcePostAttribution *sourceAttribution; +@property (nonatomic, strong) RemoteReaderCrossPostMeta *crossPostMeta; + +@property (nonatomic, strong) NSString *primaryTag; +@property (nonatomic, strong) NSString *primaryTagSlug; +@property (nonatomic, strong) NSString *secondaryTag; +@property (nonatomic, strong) NSString *secondaryTagSlug; +@property (nonatomic) BOOL isExternal; +@property (nonatomic) BOOL isJetpack; +@property (nonatomic) NSNumber *wordCount; +@property (nonatomic) NSNumber *readingTime; +@property (nonatomic, strong) NSString *railcar; + +@property (nonatomic) BOOL canSubscribeComments; +@property (nonatomic) BOOL isSubscribedComments; +@property (nonatomic) BOOL receivesCommentNotifications; + +// Base Post Model +@property (nonatomic, strong) NSNumber *authorID; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *content; +@property (nonatomic, strong) NSString *date_created_gmt; +@property (nonatomic, strong) NSString *permalink; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSString *postTitle; +@property (nonatomic, strong) NSString *status; + +- (instancetype)initWithDictionary:(NSDictionary *)dict; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.m b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.m new file mode 100644 index 000000000000..ee0032eda4fa --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.m @@ -0,0 +1,718 @@ +#import "RemoteReaderPost.h" +#import "RemoteSourcePostAttribution.h" +#import "WPKit-Swift.h" + +@import NSObject_SafeExpectations; +@import WordPressShared; + +// REST Post dictionary keys +NSString * const PostRESTKeyAttachments = @"attachments"; +NSString * const PostRESTKeyAuthor = @"author"; +NSString * const PostRESTKeyAvatarURL = @"avatar_URL"; +NSString * const PostRESTKeyCommentCount = @"comment_count"; +NSString * const PostRESTKeyCommentsOpen = @"comments_open"; +NSString * const PostRESTKeyContent = @"content"; +NSString * const PostRESTKeyDate = @"date"; +NSString * const PostRESTKeyDateLiked = @"date_liked"; +NSString * const PostRESTKeyDiscoverMetadata = @"discover_metadata"; +NSString * const PostRESTKeyDiscussion = @"discussion"; +NSString * const PostRESTKeyEditorial = @"editorial"; +NSString * const PostRESTKeyEmail = @"email"; +NSString * const PostRESTKeyExcerpt = @"excerpt"; +NSString * const PostRESTKeyFeaturedMedia = @"featured_media"; +NSString * const PostRESTKeyFeaturedImage = @"featured_image"; +NSString * const PostRESTKeyFeedID = @"feed_ID"; +NSString * const PostRESTKeyFeedItemID = @"feed_item_ID"; +NSString * const PostRESTKeyGlobalID = @"global_ID"; +NSString * const PostRESTKeyHighlightTopic = @"highlight_topic"; +NSString * const PostRESTKeyHighlightTopicTitle = @"highlight_topic_title"; +NSString * const PostRESTKeyILike = @"i_like"; +NSString * const PostRESTKeyID = @"ID"; +NSString * const PostRESTKeyIsExternal = @"is_external"; +NSString * const PostRESTKeyIsFollowing = @"is_following"; +NSString * const PostRESTKeyIsJetpack = @"is_jetpack"; +NSString * const PostRESTKeyIsReblogged = @"is_reblogged"; +NSString * const PostRESTKeyIsSeen = @"is_seen"; +NSString * const PostRESTKeyLikeCount = @"like_count"; +NSString * const PostRESTKeyLikesEnabled = @"likes_enabled"; +NSString * const PostRESTKeyName = @"name"; +NSString * const PostRESTKeyNiceName = @"nice_name"; +NSString * const PostRESTKeyPermalink = @"permalink"; +NSString * const PostRESTKeyPostCount = @"post_count"; +NSString * const PostRESTKeyScore = @"score"; +NSString * const PostRESTKeySharingEnabled = @"sharing_enabled"; +NSString * const PostRESTKeySiteID = @"site_ID"; +NSString * const PostRESTKeySiteIsAtomic = @"site_is_atomic"; +NSString * const PostRESTKeySiteIsPrivate = @"site_is_private"; +NSString * const PostRESTKeySiteName = @"site_name"; +NSString * const PostRESTKeySiteURL = @"site_URL"; +NSString * const PostRESTKeySlug = @"slug"; +NSString * const PostRESTKeyStatus = @"status"; +NSString * const PostRESTKeyTitle = @"title"; +NSString * const PostRESTKeyTaggedOn = @"tagged_on"; +NSString * const PostRESTKeyTags = @"tags"; +NSString * const POSTRESTKeyTagDisplayName = @"display_name"; +NSString * const PostRESTKeyURL = @"URL"; +NSString * const PostRESTKeyWordCount = @"word_count"; +NSString * const PostRESTKeyRailcar = @"railcar"; +NSString * const PostRESTKeyOrganizationID = @"meta.data.site.organization_id"; +NSString * const PostRESTKeyCanSubscribeComments = @"can_subscribe_comments"; +NSString * const PostRESTKeyIsSubscribedComments = @"is_subscribed_comments"; +NSString * const POSTRESTKeyReceivesCommentNotifications = @"subscribed_comments_notifications"; + +// Tag dictionary keys +NSString * const TagKeyPrimary = @"primaryTag"; +NSString * const TagKeyPrimarySlug = @"primaryTagSlug"; +NSString * const TagKeySecondary = @"secondaryTag"; +NSString * const TagKeySecondarySlug = @"secondaryTagSlug"; + +// XPost Meta Keys +NSString * const PostRESTKeyMetadata = @"metadata"; +NSString * const CrossPostMetaKey = @"key"; +NSString * const CrossPostMetaValue = @"value"; +NSString * const CrossPostMetaXPostPermalink = @"_xpost_original_permalink"; +NSString * const CrossPostMetaXCommentPermalink = @"xcomment_original_permalink"; +NSString * const CrossPostMetaXPostOrigin = @"xpost_origin"; +NSString * const CrossPostMetaCommentPrefix = @"comment-"; + +static const NSInteger AvgWordsPerMinuteRead = 250; +static const NSInteger MinutesToReadThreshold = 2; +static const NSUInteger ReaderPostTitleLength = 30; + +@implementation RemoteReaderPost + +/** + Sanitizes a post object from the REST API. + + @param dict A dictionary representing a post object from the REST API + @return A `RemoteReaderPost` object + */ +- (instancetype)initWithDictionary:(NSDictionary *)dict; +{ + NSDictionary *authorDict = [dict dictionaryForKey:PostRESTKeyAuthor]; + NSDictionary *discussionDict = [dict dictionaryForKey:PostRESTKeyDiscussion] ?: dict; + + self.authorID = [authorDict numberForKey:PostRESTKeyID]; + self.author = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyNiceName]]; // typically the author's screen name + self.authorAvatarURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyAvatarURL]]; + self.authorDisplayName = [[self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyName]] stringByDecodingXMLCharacters]; // Typically the author's given name + self.authorEmail = [self authorEmailFromAuthorDictionary:authorDict]; + self.authorURL = [self stringOrEmptyString:[authorDict stringForKey:PostRESTKeyURL]]; + self.siteIconURL = [self stringOrEmptyString:[dict stringForKeyPath:@"meta.data.site.icon.img"]]; + self.blogName = [self siteNameFromPostDictionary:dict]; + self.blogDescription = [self siteDescriptionFromPostDictionary:dict]; + self.blogURL = [self siteURLFromPostDictionary:dict]; + self.commentCount = [discussionDict numberForKey:PostRESTKeyCommentCount]; + self.commentsOpen = [[discussionDict numberForKey:PostRESTKeyCommentsOpen] boolValue]; + self.content = [self postContentFromPostDictionary:dict]; + self.date_created_gmt = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyDate]]; + self.featuredImage = [self featuredImageFromPostDictionary:dict]; + self.feedID = [dict numberForKey:PostRESTKeyFeedID]; + self.feedItemID = [dict numberForKey:PostRESTKeyFeedItemID]; + self.globalID = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyGlobalID]]; + self.isBlogAtomic = [self siteIsAtomicFromPostDictionary:dict]; + self.isBlogPrivate = [self siteIsPrivateFromPostDictionary:dict]; + self.isFollowing = [[dict numberForKey:PostRESTKeyIsFollowing] boolValue]; + self.isLiked = [[dict numberForKey:PostRESTKeyILike] boolValue]; + self.isReblogged = [[dict numberForKey:PostRESTKeyIsReblogged] boolValue]; + self.isWPCom = [self isWPComFromPostDictionary:dict]; + self.likeCount = [dict numberForKey:PostRESTKeyLikeCount]; + self.permalink = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyURL]]; + self.postID = [dict numberForKey:PostRESTKeyID]; + self.postTitle = [self postTitleFromPostDictionary:dict]; + self.score = [dict numberForKey:PostRESTKeyScore]; + self.siteID = [dict numberForKey:PostRESTKeySiteID]; + self.sortDate = [self sortDateFromPostDictionary:dict]; + self.sortRank = @(self.sortDate.timeIntervalSinceReferenceDate); + self.status = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyStatus]]; + self.summary = [self postSummaryFromPostDictionary:dict orPostContent:self.content]; + self.tags = [self tagsFromPostDictionary:dict]; + self.isSharingEnabled = [[dict numberForKey:PostRESTKeySharingEnabled] boolValue]; + self.isLikesEnabled = [[dict numberForKey:PostRESTKeyLikesEnabled] boolValue]; + self.organizationID = [dict numberForKeyPath:PostRESTKeyOrganizationID] ?: @0; + self.canSubscribeComments = [[dict numberForKey:PostRESTKeyCanSubscribeComments] boolValue]; + self.isSubscribedComments = [[dict numberForKey:PostRESTKeyIsSubscribedComments] boolValue]; + self.receivesCommentNotifications = [[dict numberForKey:POSTRESTKeyReceivesCommentNotifications] boolValue]; + + if ([dict numberForKey:PostRESTKeyIsSeen]) { + self.isSeen = [[dict numberForKey:PostRESTKeyIsSeen] boolValue]; + self.isSeenSupported = YES; + } else { + self.isSeen = YES; + self.isSeenSupported = NO; + } + + // Construct a title if necessary. + if ([self.postTitle length] == 0 && [self.summary length] > 0) { + self.postTitle = [self titleFromSummary:self.summary]; + } + + NSDictionary *tags = [self primaryAndSecondaryTagsFromPostDictionary:dict]; + if (tags) { + self.primaryTag = [tags stringForKey:TagKeyPrimary]; + self.primaryTagSlug = [tags stringForKey:TagKeyPrimarySlug]; + self.secondaryTag = [tags stringForKey:TagKeySecondary]; + self.secondaryTagSlug = [tags stringForKey:TagKeySecondarySlug]; + } + + self.isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; + self.isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; + self.wordCount = [dict numberForKey:PostRESTKeyWordCount]; + self.readingTime = [self readingTimeForWordCount:self.wordCount]; + + NSDictionary *railcar = [dict dictionaryForKey:PostRESTKeyRailcar]; + if (railcar) { + NSError *error; + NSData *railcarData = [NSJSONSerialization dataWithJSONObject:railcar options:NSJSONWritingPrettyPrinted error:&error]; + self.railcar = [[NSString alloc] initWithData:railcarData encoding:NSUTF8StringEncoding]; + } + + if ([dict arrayForKeyPath:@"discover_metadata.discover_fp_post_formats"]) { + self.sourceAttribution = [self sourceAttributionFromDictionary:[dict dictionaryForKey:PostRESTKeyDiscoverMetadata]]; + } + + RemoteReaderCrossPostMeta *crossPostMeta = [self crossPostMetaFromPostDictionary:dict]; + if (crossPostMeta) { + self.crossPostMeta = crossPostMeta; + } + + return self; +} + +- (RemoteReaderCrossPostMeta *)crossPostMetaFromPostDictionary:(NSDictionary *)dict +{ + BOOL crossPostMetaFound = NO; + + RemoteReaderCrossPostMeta *meta = [RemoteReaderCrossPostMeta new]; + + NSArray *metadata = [dict arrayForKey:PostRESTKeyMetadata]; + for (NSDictionary *obj in metadata) { + if (![obj isKindOfClass:[NSDictionary class]]) { + continue; + } + if ([[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXPostPermalink] || + [[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXCommentPermalink]) { + + NSString *path = [obj stringForKey:CrossPostMetaValue]; + NSURL *url = [NSURL URLWithString:path]; + if (url) { + meta.siteURL = [NSString stringWithFormat:@"%@://%@", url.scheme, url.host]; + meta.postURL = [NSString stringWithFormat:@"%@%@", meta.siteURL, url.path]; + if ([url.fragment hasPrefix:CrossPostMetaCommentPrefix]) { + meta.commentURL = [url absoluteString]; + } + } + } else if ([[obj stringForKey:CrossPostMetaKey] isEqualToString:CrossPostMetaXPostOrigin]) { + NSString *value = [obj stringForKey:CrossPostMetaValue]; + NSArray *IDS = [value componentsSeparatedByString:@":"]; + meta.siteID = [[IDS firstObject] numericValue]; + meta.postID = [[IDS lastObject] numericValue]; + + crossPostMetaFound = YES; + } + } + + if (!crossPostMetaFound) { + return nil; + } + + return meta; +} + +- (NSDictionary *)primaryAndSecondaryTagsFromPostDictionary:(NSDictionary *)dict +{ + NSString *primaryTag = @""; + NSString *primaryTagSlug = @""; + NSString *secondaryTag = @""; + NSString *secondaryTagSlug = @""; + NSString *editorialTag; + NSString *editorialSlug; + + // Loop over all the tags. + // If the current tag's post count is greater than the previous post count, + // make it the new primary tag, and make a previous primary tag the secondary tag. + NSArray *remoteTags = [[dict dictionaryForKey:PostRESTKeyTags] allValues]; + if (remoteTags) { + NSInteger highestCount = 0; + NSInteger secondHighestCount = 0; + NSString *tagTitle; + for (NSDictionary *tag in remoteTags) { + NSInteger count = [[tag numberForKey:PostRESTKeyPostCount] integerValue]; + if (count > highestCount) { + secondaryTag = primaryTag; + secondaryTagSlug = primaryTagSlug; + secondHighestCount = highestCount; + + tagTitle = [tag stringForKey:POSTRESTKeyTagDisplayName] ?: [tag stringForKey:PostRESTKeyName]; + primaryTag = tagTitle ?: @""; + primaryTagSlug = [tag stringForKey:PostRESTKeySlug] ?: @""; + highestCount = count; + + } else if (count > secondHighestCount) { + tagTitle = [tag stringForKey:POSTRESTKeyTagDisplayName] ?: [tag stringForKey:PostRESTKeyName]; + secondaryTag = tagTitle ?: @""; + secondaryTagSlug = [tag stringForKey:PostRESTKeySlug] ?: @""; + secondHighestCount = count; + + } + } + } + + NSDictionary *editorial = [dict dictionaryForKey:PostRESTKeyEditorial]; + if (editorial) { + editorialSlug = [editorial stringForKey:PostRESTKeyHighlightTopic]; + editorialTag = [editorial stringForKey:PostRESTKeyHighlightTopicTitle] ?: [editorialSlug capitalizedString]; + } + + if (editorialSlug) { + secondaryTag = primaryTag; + secondaryTagSlug = primaryTagSlug; + primaryTag = editorialTag; + primaryTagSlug = editorialSlug; + } + + primaryTag = [primaryTag stringByDecodingXMLCharacters]; + secondaryTag = [secondaryTag stringByDecodingXMLCharacters]; + + return @{ + TagKeyPrimary:primaryTag, + TagKeyPrimarySlug:primaryTagSlug, + TagKeySecondary:secondaryTag, + TagKeySecondarySlug:secondaryTagSlug, + }; +} + +- (NSNumber *)readingTimeForWordCount:(NSNumber *)wordCount +{ + NSInteger count = [wordCount integerValue]; + NSInteger minutesToRead = count / AvgWordsPerMinuteRead; + if (minutesToRead < MinutesToReadThreshold) { + return @(0); + } + return @(minutesToRead); +} + +/** + Composes discover attribution if needed. + + @param dict A dictionary representing a discover_metadata object from the REST API + @return A `RemoteDiscoverAttribution` object + */ +- (RemoteSourcePostAttribution *)sourceAttributionFromDictionary:(NSDictionary *)dict +{ + NSArray *taxonomies = [dict arrayForKey:@"discover_fp_post_formats"]; + if ([taxonomies count] == 0) { + return nil; + } + + RemoteSourcePostAttribution *sourceAttr = [RemoteSourcePostAttribution new]; + sourceAttr.permalink = [dict stringForKey:PostRESTKeyPermalink]; + sourceAttr.authorName = [dict stringForKeyPath:@"attribution.author_name"]; + sourceAttr.authorURL = [dict stringForKeyPath:@"attribution.author_url"]; + sourceAttr.avatarURL = [dict stringForKeyPath:@"attribution.avatar_url"]; + sourceAttr.blogName = [dict stringForKeyPath:@"attribution.blog_name"]; + sourceAttr.blogURL = [dict stringForKeyPath:@"attribution.blog_url"]; + sourceAttr.blogID = [dict numberForKeyPath:@"featured_post_wpcom_data.blog_id"]; + sourceAttr.postID = [dict numberForKeyPath:@"featured_post_wpcom_data.post_id"]; + sourceAttr.commentCount = [dict numberForKeyPath:@"featured_post_wpcom_data.comment_count"]; + sourceAttr.likeCount = [dict numberForKeyPath:@"featured_post_wpcom_data.like_count"]; + sourceAttr.taxonomies = [self slugsFromDiscoverPostTaxonomies:taxonomies]; + return sourceAttr; +} + + +#pragma mark - Utils + +/** + Checks the value of the string passed. If the string is nil, an empty string is returned. + + @param str The string to check for nil. + @ Returns the string passed if it was not nil, or an empty string if the value passed was nil. + */ +- (NSString *)stringOrEmptyString:(NSString *)str +{ + if (!str) { + return @""; + } + return str; +} + +/** + Format a featured image url into an expected format. + + @param img The URL path to the featured image. + @return A sanitized URL. + */ +- (NSString *)sanitizeFeaturedImageString:(NSString *)img +{ + if (!img) { + return [NSString string]; + } + NSRange mshotRng = [img rangeOfString:@"wp.com/mshots/"]; + if (NSNotFound != mshotRng.location) { + // MShots are sceen caps of the actual site. There URLs look like this: + // https://s0.wp.com/mshots/v1/http%3A%2F%2Fsitename.wordpress.com%2F2013%2F05%2F13%2Fr-i-p-mom%2F?w=252 + // We want the mshot URL but not the size info in the query string. + NSRange rng = [img rangeOfString:@"?" options:NSBackwardsSearch]; + if (rng.location != NSNotFound) { + img = [img substringWithRange:NSMakeRange(0, rng.location)]; + } + return img; + } + + NSRange imgPressRng = [img rangeOfString:@"wp.com/imgpress"]; + if (imgPressRng.location != NSNotFound) { + // ImagePress urls look like this: + // https://s0.wp.com/imgpress?resize=252%2C160&url=http%3A%2F%2Fsitename.files.wordpress.com%2F2014%2F04%2Fimage-name.jpg&unsharpmask=80,0.5,3 + // We want the URL of the image being sent to ImagePress without all the ImagePress stuff + + // Find the start of the actual URL for the image + NSRange httpRng = [img rangeOfString:@"http" options:NSBackwardsSearch]; + NSInteger location = 0; + if (httpRng.location != NSNotFound) { + location = httpRng.location; + } + + // Find the last of the image press options after the image URL + // Search from the start of the URL to the end of the string + NSRange ampRng = [img rangeOfString:@"&" options:NSLiteralSearch range:NSMakeRange(location, [img length] - location)]; + // Default length is the remainder of the string following the start of the image URL. + NSInteger length = [img length] - location; + if (ampRng.location != NSNotFound) { + // The actual length is the location of the first ampersand after the starting index of the image URL, minus the starting index of the image URL. + length = ampRng.location - location; + } + + // Retrieve the image URL substring from the range. + img = [img substringWithRange:NSMakeRange(location, length)]; + + // Actually decode twice to remove the encodings + img = [img stringByRemovingPercentEncoding]; + img = [img stringByRemovingPercentEncoding]; + } + return img; +} + +#pragma mark - Data sanitization methods + +/** + The v1 API result is inconsistent in that it will return a 0 when there is no author email. + + @param dict The author dictionary. + @return The author's email address or an empty string. + */ +- (NSString *)authorEmailFromAuthorDictionary:(NSDictionary *)dict +{ + NSString *authorEmail = [dict stringForKey:PostRESTKeyEmail]; + + // if 0 or less than minimum email length. a@a.aa + if ([authorEmail isEqualToString:@"0"] || [authorEmail length] < 6) { + authorEmail = @""; + } + + return authorEmail; +} + +/** + Parse whether the post belongs to a wpcom blog. + + @param dict A dictionary representing a post object from the REST API + @return YES if the post belongs to a wpcom blog, else NO + */ +- (BOOL)isWPComFromPostDictionary:(NSDictionary *)dict +{ + BOOL isExternal = [[dict numberForKey:PostRESTKeyIsExternal] boolValue]; + BOOL isJetpack = [[dict numberForKey:PostRESTKeyIsJetpack] boolValue]; + + return !isJetpack && !isExternal; +} + +/** + Get the tags assigned to a post and return them as a comma separated string. + + @param dict A dictionary representing a post object from the REST API. + @return A comma separated list of tags, or an empty string if no tags are found. + */ +- (NSString *)tagsFromPostDictionary:(NSDictionary *)dict +{ + NSDictionary *tagsDict = [dict dictionaryForKey:PostRESTKeyTags]; + NSArray *tagsList = [NSArray arrayWithArray:[tagsDict allKeys]]; + NSString *tags = [tagsList componentsJoinedByString:@", "]; + if (tags == nil) { + tags = @""; + } + return tags; +} + +/** + Get the date the post should be sorted by. + + @param dict A dictionary representing a post object from the REST API. + @return The NSDate that should be used when sorting the post. + */ +- (NSDate *)sortDateFromPostDictionary:(NSDictionary *)dict +{ + // Sort date varies depending on the endpoint we're fetching from. + NSString *sortDate = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyDate]]; + + // Date tagged on is returned by read/tags/%s/posts endpoints. + NSString *taggedDate = [dict stringForKey:PostRESTKeyTaggedOn]; + if (taggedDate != nil) { + sortDate = taggedDate; + } + + // Date liked is returned by the read/liked end point. Use this for sorting recent likes. + NSString *likedDate = [dict stringForKey:PostRESTKeyDateLiked]; + if (likedDate != nil) { + sortDate = likedDate; + } + + // Values set in editorial trumps the rest + NSString *editorialDate = [dict stringForKeyPath:@"editorial.displayed_on"]; + if (editorialDate != nil) { + sortDate = editorialDate; + } + + return [DateUtils dateFromISOString:sortDate]; +} + +/** + Get the url path of the featured image to use for a post. + + @param dict A dictionary representing a post object from the REST API. + @return The url path for the featured image or an empty string. + */ +- (NSString *)featuredImageFromPostDictionary:(NSDictionary *)dict +{ + // Editorial trumps all + NSString *featuredImage = [self editorialImageFromPostDictionary:dict]; + + // Second option is the user specified featured image + if ([featuredImage length] == 0) { + featuredImage = [self userSpecifiedFeaturedImageFromPostDictionary:dict]; + } + + // If that's not present look for an image in featured media + if ([featuredImage length] == 0) { + featuredImage = [self featuredMediaImageFromPostDictionary:dict]; + } + + // As a last resource lets look for a suitable image in the post content + if ([featuredImage length] == 0) { + featuredImage = [self suitableImageFromPostContent:dict]; + } + + featuredImage = [self sanitizeFeaturedImageString:featuredImage]; + + return featuredImage; +} + +- (NSString *)editorialImageFromPostDictionary:(NSDictionary *)dict { + return [dict stringForKeyPath:@"editorial.image"]; +} + +- (NSString *)userSpecifiedFeaturedImageFromPostDictionary:(NSDictionary *)dict { + return [dict stringForKey:PostRESTKeyFeaturedImage]; +} + +- (NSString *)featuredMediaImageFromPostDictionary:(NSDictionary *)dict { + NSDictionary *featuredMedia = [dict dictionaryForKey:PostRESTKeyFeaturedMedia]; + if ([[featuredMedia stringForKey:@"type"] isEqualToString:@"image"]) { + return [featuredMedia stringForKey:@"uri"]; + } + return nil; +} + +- (NSString *)suitableImageFromPostContent:(NSDictionary *)dict { + NSString *content = [dict stringForKey:PostRESTKeyContent]; + NSString *imageToDisplay = [DisplayableImageHelper searchPostContentForImageToDisplay:content]; + return [self stringOrEmptyString:imageToDisplay]; +} + +/** + Get the name of the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return The name of the post's site or an empty string. + */ +- (NSString *)siteNameFromPostDictionary:(NSDictionary *)dict +{ + // Blog Name + NSString *siteName = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteName]]; + + // For some endpoints blogname is defined in meta + NSString *metaBlogName = [dict stringForKeyPath:@"meta.data.site.name"]; + if (metaBlogName != nil) { + siteName = metaBlogName; + } + + // Values set in editorial trumps the rest + NSString *editorialSiteName = [dict stringForKeyPath:@"editorial.blog_name"]; + if (editorialSiteName != nil) { + siteName = editorialSiteName; + } + + return [self makePlainText:siteName]; +} + +/** + Get the description of the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return The description of the post's site or an empty string. + */ +- (NSString *)siteDescriptionFromPostDictionary:(NSDictionary *)dict +{ + NSString *description = [self stringOrEmptyString:[dict stringForKeyPath:@"meta.data.site.description"]]; + return [self makePlainText:description]; +} + +/** + Retrives the post site's URL + + @param dict A dictionary representing a post object from the REST API. + @return The URL path of the post's site. + */ +- (NSString *)siteURLFromPostDictionary:(NSDictionary *)dict +{ + NSString *siteURL = [self stringOrEmptyString:[dict stringForKey:PostRESTKeySiteURL]]; + + NSString *metaSiteURL = [dict stringForKeyPath:@"meta.data.site.URL"]; + if (metaSiteURL != nil) { + siteURL = metaSiteURL; + } + + return siteURL; +} + +/** + Retrives the post content from results dictionary + + @param dict A dictionary representing a post object from the REST API. + @return The formatted post content. + */ +- (NSString *)postContentFromPostDictionary:(NSDictionary *)dict { + NSString *content = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyContent]]; + + return content; +} + +/** + Get the title of the post + + @param dict A dictionary representing a post object from the REST API. + @return The title of the post or an empty string. + */ +- (NSString *)postTitleFromPostDictionary:(NSDictionary *)dict { + NSString *title = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyTitle]]; + return [self makePlainText:title]; +} + +/** + Get the summary for the post, or crafts one from the post content. + + @param dict A dictionary representing a post object from the REST API. + @param content The formatted post content. + @return The summary for the post or an empty string. + */ +- (NSString *)postSummaryFromPostDictionary:(NSDictionary *)dict orPostContent:(NSString *)content { + NSString *summary = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyExcerpt]]; + summary = [self formatSummary:summary]; + if (!summary) { + summary = [self createSummaryFromContent:content]; + } + return summary; +} + +- (BOOL)siteIsAtomicFromPostDictionary:(NSDictionary *)dict +{ + NSNumber *isAtomic = [dict numberForKey:PostRESTKeySiteIsAtomic]; + + return [isAtomic boolValue]; +} + +/** + Retrives the privacy preference for the post's site. + + @param dict A dictionary representing a post object from the REST API. + @return YES if the site is private. + */ +- (BOOL)siteIsPrivateFromPostDictionary:(NSDictionary *)dict +{ + NSNumber *isPrivate = [dict numberForKey:PostRESTKeySiteIsPrivate]; + + NSNumber *metaIsPrivate = [dict numberForKeyPath:@"meta.data.site.is_private"]; + if (metaIsPrivate != nil) { + isPrivate = metaIsPrivate; + } + + return [isPrivate boolValue]; +} + +- (NSArray *)slugsFromDiscoverPostTaxonomies:(NSArray *)discoverPostTaxonomies +{ + return [discoverPostTaxonomies wp_map:^id(NSDictionary *dict) { + return [dict stringForKey:PostRESTKeySlug]; + }]; +} + + +#pragma mark - Content Formatting and Sanitization + +/** + Formats a post's summary. The excerpts provided by the REST API contain HTML and have some extra content appened to the end. + HTML is stripped and the extra bit is removed. + + @param summary The summary to format. + @return The formatted summary. + */ +- (NSString *)formatSummary:(NSString *)summary +{ + summary = [self makePlainText:summary]; + + NSString *continueReading = NSLocalizedString(@"Continue reading", @"Part of a prompt suggesting that there is more content for the user to read."); + continueReading = [NSString stringWithFormat:@"%@ →", continueReading]; + + NSRange rng = [summary rangeOfString:continueReading options:NSCaseInsensitiveSearch]; + if (rng.location != NSNotFound) { + summary = [summary substringToIndex:rng.location]; + } + + return summary; +} + +/** + Create a summary for the post based on the post's content. + + @param string The post's content string. This should be the formatted content string. + @return A summary for the post. + */ +- (NSString *)createSummaryFromContent:(NSString *)string +{ + return [string summarized]; +} + +/** + Transforms the specified string to plain text. HTML markup is removed and HTML entities are decoded. + + @param string The string to transform. + @return The transformed string. + */ +- (NSString *)makePlainText:(NSString *)string +{ + return [string summarized]; +} + +/** + Creates a title for the post from the post's summary. + + @param summary The already formatted post summary. + @return A title for the post that is a snippet of the summary. + */ +- (NSString *)titleFromSummary:(NSString *)summary +{ + return [summary stringByEllipsizingWithMaxLength:ReaderPostTitleLength preserveWords:YES]; +} + + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.swift new file mode 100644 index 000000000000..f3c9998972b1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderPost.swift @@ -0,0 +1,17 @@ +import Foundation + +struct ReaderPostsEnvelope: Decodable { + var posts: [RemoteReaderPost] + var nextPageHandle: String? + + private enum CodingKeys: String, CodingKey { + case posts + case nextPageHandle = "next_page_handle" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let postDictionary = try container.decode([String: Any].self, forKey: .posts) + posts = [RemoteReaderPost(dictionary: postDictionary)] + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSimplePost.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSimplePost.swift new file mode 100644 index 000000000000..c8896ac8bc72 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSimplePost.swift @@ -0,0 +1,66 @@ +import Foundation + +struct RemoteReaderSimplePostEnvelope: Decodable { + let posts: [RemoteReaderSimplePost] +} + +public struct RemoteReaderSimplePost: Decodable { + public enum PostType: Int { + case local + case global + case unknown + } + + public let postID: Int + public let postUrl: String + public let siteID: Int + public let isFollowing: Bool + public let title: String + public let author: RemoteReaderSimplePostAuthor + public let excerpt: String + public let siteName: String + public let featuredImageUrl: String? + public let featuredMedia: RemoteReaderSimplePostFeaturedMedia? + public let railcar: RemoteReaderSimplePostRailcar + + public var postType: PostType { + switch railcar.fetchAlgo { + case let algoStr where algoStr.contains("local"): + return .local + case let algoStr where algoStr.contains("global"): + return .global + default: + return .unknown + } + } + + private enum CodingKeys: String, CodingKey { + case postID = "ID" + case postUrl = "URL" + case siteID = "site_ID" + case isFollowing = "is_following" + case title + case author + case excerpt + case siteName = "site_name" + case featuredImageUrl = "featured_image" + case featuredMedia = "featured_media" + case railcar + } +} + +public struct RemoteReaderSimplePostAuthor: Decodable { + public let name: String +} + +public struct RemoteReaderSimplePostFeaturedMedia: Decodable { + public let uri: String? +} + +public struct RemoteReaderSimplePostRailcar: Decodable { + public let fetchAlgo: String + + private enum CodingKeys: String, CodingKey { + case fetchAlgo = "fetch_algo" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSite.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSite.swift new file mode 100644 index 000000000000..6de803c9b32a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSite.swift @@ -0,0 +1,13 @@ +import Foundation + +@objcMembers public class RemoteReaderSite: NSObject { + + public var recordID: NSNumber! + public var siteID: NSNumber! + public var feedID: NSNumber! + public var name: String! + public var path: String! // URL + public var icon: String! // Sites only + public var isSubscribed: Bool = false + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfo.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfo.swift new file mode 100644 index 000000000000..cde802405277 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfo.swift @@ -0,0 +1,154 @@ +import Foundation +import NSObject_SafeExpectations + +// Site Topic Keys +private let SiteDictionaryFeedIDKey = "feed_ID" +private let SiteDictionaryFeedURLKey = "feed_URL" +private let SiteDictionaryFollowingKey = "is_following" +private let SiteDictionaryJetpackKey = "is_jetpack" +private let SiteDictionaryOrganizationID = "organization_id" +private let SiteDictionaryPrivateKey = "is_private" +private let SiteDictionaryVisibleKey = "visible" +private let SiteDictionaryPostCountKey = "post_count" +private let SiteDictionaryIconPathKey = "icon.img" +private let SiteDictionaryDescriptionKey = "description" +private let SiteDictionaryIDKey = "ID" +private let SiteDictionaryNameKey = "name" +private let SiteDictionaryURLKey = "URL" +private let SiteDictionarySubscriptionsKey = "subscribers_count" +private let SiteDictionarySubscriptionKey = "subscription" +private let SiteDictionaryUnseenCountKey = "unseen_count" + +// Subscription keys +private let SubscriptionDeliveryMethodsKey = "delivery_methods" + +// Delivery methods keys +private let DeliveryMethodEmailKey = "email" +private let DeliveryMethodNotificationKey = "notification" + +@objcMembers public class RemoteReaderSiteInfo: NSObject { + public var feedID: NSNumber? + public var feedURL: String? + public var isFollowing: Bool = false + public var isJetpack: Bool = false + public var isPrivate: Bool = false + public var isVisible: Bool = false + public var organizationID: NSNumber? + public var postCount: NSNumber? + public var siteBlavatar: String? + public var siteDescription: String? + public var siteID: NSNumber? + public var siteName: String? + public var siteURL: String? + public var subscriberCount: NSNumber? + public var unseenCount: NSNumber? + public var postsEndpoint: String? + public var endpointPath: String? + + public var postSubscription: RemoteReaderSiteInfoSubscriptionPost? + public var emailSubscription: RemoteReaderSiteInfoSubscriptionEmail? + + public class func siteInfo(forSiteResponse response: NSDictionary, isFeed: Bool) -> RemoteReaderSiteInfo { + if isFeed { + return siteInfo(forFeedResponse: response) + } + + let siteInfo = RemoteReaderSiteInfo() + siteInfo.feedID = response.number(forKey: SiteDictionaryFeedIDKey) + siteInfo.feedURL = response.string(forKey: SiteDictionaryFeedURLKey) + siteInfo.isFollowing = response.number(forKey: SiteDictionaryFollowingKey)?.boolValue ?? false + siteInfo.isJetpack = response.number(forKey: SiteDictionaryJetpackKey)?.boolValue ?? false + siteInfo.isPrivate = response.number(forKey: SiteDictionaryPrivateKey)?.boolValue ?? false + siteInfo.isVisible = response.number(forKey: SiteDictionaryVisibleKey)?.boolValue ?? false + siteInfo.organizationID = response.number(forKey: SiteDictionaryOrganizationID) ?? 0 + siteInfo.postCount = response.number(forKey: SiteDictionaryPostCountKey) + siteInfo.siteBlavatar = response.string(forKeyPath: SiteDictionaryIconPathKey) + siteInfo.siteDescription = response.string(forKey: SiteDictionaryDescriptionKey) + siteInfo.siteID = response.number(forKey: SiteDictionaryIDKey) + siteInfo.siteName = response.string(forKey: SiteDictionaryNameKey) + siteInfo.siteURL = response.string(forKey: SiteDictionaryURLKey) + siteInfo.subscriberCount = response.number(forKey: SiteDictionarySubscriptionsKey) ?? 0 + siteInfo.unseenCount = response.number(forKey: SiteDictionaryUnseenCountKey) ?? 0 + + if (siteInfo.siteName?.count ?? 0) == 0, + let siteURLString = siteInfo.siteURL, + let siteURL = URL(string: siteURLString) { + siteInfo.siteName = siteURL.host + } + + siteInfo.endpointPath = "read/sites/\(siteInfo.siteID ?? 0)/posts/" + + if let subscription = response[SiteDictionarySubscriptionKey] as? NSDictionary { + siteInfo.postSubscription = postSubscription(forSubscription: subscription) + siteInfo.emailSubscription = emailSubscription(forSubscription: subscription) + } + + return siteInfo + } + +} + +private extension RemoteReaderSiteInfo { + class func siteInfo(forFeedResponse response: NSDictionary) -> RemoteReaderSiteInfo { + let siteInfo = RemoteReaderSiteInfo() + siteInfo.feedID = response.number(forKey: SiteDictionaryFeedIDKey) + siteInfo.feedURL = response.string(forKey: SiteDictionaryFeedURLKey) + siteInfo.isFollowing = response.number(forKey: SiteDictionaryFollowingKey)?.boolValue ?? false + siteInfo.isJetpack = false + siteInfo.isPrivate = false + siteInfo.isVisible = true + siteInfo.postCount = 0 + siteInfo.siteBlavatar = "" + siteInfo.siteDescription = "" + siteInfo.siteID = 0 + siteInfo.siteName = response.string(forKey: SiteDictionaryNameKey) + siteInfo.siteURL = response.string(forKey: SiteDictionaryURLKey) + siteInfo.subscriberCount = response.number(forKey: SiteDictionarySubscriptionsKey) ?? 0 + + if (siteInfo.siteName?.count ?? 0) == 0, + let siteURLString = siteInfo.siteURL, + let siteURL = URL(string: siteURLString) { + siteInfo.siteName = siteURL.host + } + + siteInfo.endpointPath = "read/feed/\(siteInfo.feedID ?? 0)/posts/" + + return siteInfo + } + + /// Generate an Site Info Post Subscription object + /// + /// - Parameter subscription A dictionary object for the site subscription + /// - Returns A nullable Site Info Post Subscription + class func postSubscription(forSubscription subscription: NSDictionary) -> RemoteReaderSiteInfoSubscriptionPost? { + guard subscription.wp_isValidObject() else { + return nil + } + + guard let deliveryMethod = subscription[SubscriptionDeliveryMethodsKey] as? [String: Any], + let method = deliveryMethod[DeliveryMethodNotificationKey] as? [String: Any] + else { + return nil + } + + return RemoteReaderSiteInfoSubscriptionPost(dictionary: method) + } + + /// Generate an Site Info Email Subscription object + /// + /// - Parameter subscription A dictionary object for the site subscription + /// - Returns A nullable Site Info Email Subscription + class func emailSubscription(forSubscription subscription: NSDictionary) -> RemoteReaderSiteInfoSubscriptionEmail? { + guard subscription.wp_isValidObject() else { + return nil + } + + guard let delieveryMethod = subscription[SubscriptionDeliveryMethodsKey] as? [String: Any], + let method = delieveryMethod[DeliveryMethodEmailKey] as? [String: Any] + else { + return nil + } + + return RemoteReaderSiteInfoSubscriptionEmail(dictionary: method) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfoSubscription.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfoSubscription.swift new file mode 100644 index 000000000000..34b758e93e2e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderSiteInfoSubscription.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Mapping keys +private struct CodingKeys { + static let sendPost = "send_posts" + static let sendComments = "send_comments" + static let postDeliveryFrequency = "post_delivery_frequency" +} + +/// Site Info Post Subscription model +@objc public class RemoteReaderSiteInfoSubscriptionPost: NSObject { + @objc public var sendPosts: Bool + + @objc required public init(dictionary: [String: Any]) { + self.sendPosts = (dictionary[CodingKeys.sendPost] as? Bool) ?? false + super.init() + } +} + +/// Site Info Email Subscription model +@objc public class RemoteReaderSiteInfoSubscriptionEmail: RemoteReaderSiteInfoSubscriptionPost { + @objc public var sendComments: Bool + @objc public var postDeliveryFrequency: String + + @objc required public init(dictionary: [String: Any]) { + sendComments = (dictionary[CodingKeys.sendComments] as? Bool) ?? false + postDeliveryFrequency = (dictionary[CodingKeys.postDeliveryFrequency] as? String) ?? "" + super.init(dictionary: dictionary) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteReaderTopic.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderTopic.swift new file mode 100644 index 000000000000..3df828ee21ab --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteReaderTopic.swift @@ -0,0 +1,43 @@ +import Foundation + +@objcMembers public class RemoteReaderTopic: NSObject { + + public var isMenuItem: Bool = false + public var isRecommended: Bool + public var isSubscribed: Bool + public var path: String? + public var slug: String? + public var title: String? + public var topicDescription: String? + public var topicID: NSNumber + public var type: String? + public var owner: String? + public var organizationID: NSNumber + + /// Create `RemoteReaderTopic` with the supplied topics dictionary, ensuring expected keys are always present. + /// + /// - Parameters: + /// - topicDict: The topic `NSDictionary` to normalize. + /// - subscribed: Whether the current account subscribes to the topic. + /// - recommended: Whether the topic is recommended. + public init(dictionary topicDict: NSDictionary, subscribed: Bool, recommended: Bool) { + topicID = topicDict.number(forKey: topicDictionaryIDKey) ?? 0 + owner = topicDict.string(forKey: topicDictionaryOwnerKey) + path = topicDict.string(forKey: topicDictionaryURLKey)?.lowercased() + slug = topicDict.string(forKey: topicDictionarySlugKey) + title = topicDict.string(forKey: topicDictionaryDisplayNameKey) ?? topicDict.string(forKey: topicDictionaryTitleKey) + type = topicDict.string(forKey: topicDictionaryTypeKey) + organizationID = topicDict.number(forKeyPath: topicDictionaryOrganizationIDKey) ?? 0 + isSubscribed = subscribed + isRecommended = recommended + } +} + +private let topicDictionaryIDKey = "ID" +private let topicDictionaryOrganizationIDKey = "organization_id" +private let topicDictionaryOwnerKey = "owner" +private let topicDictionarySlugKey = "slug" +private let topicDictionaryTitleKey = "title" +private let topicDictionaryTypeKey = "type" +private let topicDictionaryDisplayNameKey = "display_name" +private let topicDictionaryURLKey = "URL" diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteShareAppContent.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteShareAppContent.swift new file mode 100644 index 000000000000..6f9ec8515b05 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteShareAppContent.swift @@ -0,0 +1,14 @@ +/// Defines the information structure used for recommending the app to others. +/// +public struct RemoteShareAppContent: Codable { + /// A text content to share. + public let message: String + + /// A URL string that directs the recipient to a page describing steps to get the app. + public let link: String + + /// Convenience method that returns `link` as URL. + public func linkURL() -> URL? { + URL(string: link) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteSharingButton.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteSharingButton.swift new file mode 100644 index 000000000000..1f3a7f6fb6e6 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteSharingButton.swift @@ -0,0 +1,11 @@ +import Foundation + +@objc open class RemoteSharingButton: NSObject { + @objc open var buttonID = "" + @objc open var name = "" + @objc open var shortname = "" + @objc open var custom = false + @objc open var enabled = false + @objc open var visibility: String? + @objc open var order: NSNumber = 0 +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteSiteDesign.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteSiteDesign.swift new file mode 100644 index 000000000000..594448c6274a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteSiteDesign.swift @@ -0,0 +1,92 @@ +import Foundation + +public struct RemoteSiteDesigns: Codable { + public let designs: [RemoteSiteDesign] + public let categories: [RemoteSiteDesignCategory] + + enum CodingKeys: String, CodingKey { + case designs + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + designs = try map.decode([RemoteSiteDesign].self, forKey: .designs) + categories = try map.decode([RemoteSiteDesignCategory].self, forKey: .categories) + } + + public init() { + self.init(designs: [], categories: []) + } + + public init(designs: [RemoteSiteDesign], categories: [RemoteSiteDesignCategory]) { + self.designs = designs + self.categories = categories + } +} + +public struct RemoteSiteDesign: Codable { + public let slug: String + public let title: String + public let demoURL: String + public let screenshot: String? + public let mobileScreenshot: String? + public let tabletScreenshot: String? + public let themeSlug: String? + public let group: [String]? + public let segmentID: Int64? + public let categories: [RemoteSiteDesignCategory] + + enum CodingKeys: String, CodingKey { + case slug + case title + case demoURL = "demo_url" + case screenshot = "preview" + case mobileScreenshot = "preview_mobile" + case tabletScreenshot = "preview_tablet" + case themeSlug = "theme" + case group + case segmentID = "segment_id" + case categories + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + demoURL = try map.decode(String.self, forKey: .demoURL) + screenshot = try? map.decode(String.self, forKey: .screenshot) + mobileScreenshot = try? map.decode(String.self, forKey: .mobileScreenshot) + tabletScreenshot = try? map.decode(String.self, forKey: .tabletScreenshot) + themeSlug = try? map.decode(String.self, forKey: .themeSlug) + group = try? map.decode([String].self, forKey: .group) + segmentID = try? map.decode(Int64.self, forKey: .segmentID) + categories = try map.decode([RemoteSiteDesignCategory].self, forKey: .categories) + } +} + +public struct RemoteSiteDesignCategory: Codable, Comparable { + public static func < (lhs: RemoteSiteDesignCategory, rhs: RemoteSiteDesignCategory) -> Bool { + return lhs.slug < rhs.slug + } + + public let slug: String + public let title: String + public let description: String + public let emoji: String? + + enum CodingKeys: String, CodingKey { + case slug + case title + case description + case emoji + } + + public init(from decoder: Decoder) throws { + let map = try decoder.container(keyedBy: CodingKeys.self) + slug = try map.decode(String.self, forKey: .slug) + title = try map.decode(String.self, forKey: .title) + description = try map.decode(String.self, forKey: .description) + emoji = try? map.decode(String.self, forKey: .emoji) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.h b/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.h new file mode 100644 index 000000000000..52cea5a65b93 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.h @@ -0,0 +1,17 @@ +#import + +@interface RemoteSourcePostAttribution : NSObject + +@property (nonatomic, strong) NSString *permalink; +@property (nonatomic, strong) NSString *authorName; +@property (nonatomic, strong) NSString *authorURL; +@property (nonatomic, strong) NSString *blogName; +@property (nonatomic, strong) NSString *blogURL; +@property (nonatomic, strong) NSString *avatarURL; +@property (nonatomic, strong) NSNumber *blogID; +@property (nonatomic, strong) NSNumber *postID; +@property (nonatomic, strong) NSNumber *likeCount; +@property (nonatomic, strong) NSNumber *commentCount; +@property (nonatomic, strong) NSArray *taxonomies; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.m b/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.m new file mode 100644 index 000000000000..99f7d3f41d35 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteSourcePostAttribution.m @@ -0,0 +1,5 @@ +#import "RemoteSourcePostAttribution.h" + +@implementation RemoteSourcePostAttribution + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.h b/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.h new file mode 100644 index 000000000000..8b216f81ce50 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.h @@ -0,0 +1,53 @@ +#import + +typedef NS_ENUM(NSUInteger, RemoteTaxonomyPagingResultsOrder) { + RemoteTaxonomyPagingOrderAscending = 0, + RemoteTaxonomyPagingOrderDescending +}; + +typedef NS_ENUM(NSUInteger, RemoteTaxonomyPagingResultsOrdering) { + /* Order the results by the name of the taxonomy. + */ + RemoteTaxonomyPagingResultsOrderingByName = 0, + /* Order the results by the number of posts associated with the taxonomy. + */ + RemoteTaxonomyPagingResultsOrderingByCount +}; + + +/** + @class RemoteTaxonomyPaging + @brief A paging object for passing parameters to the API when requesting paged lists of taxonomies. + See each remote API for specifics regarding default values and limits. + WP.com/REST Jetpack: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/categories/ + XML-RPC: https://codex.wordpress.org/XML-RPC_WordPress_API/Taxonomies + */ +@interface RemoteTaxonomyPaging : NSObject + +/** + @brief The max number of taxonomies to return. + */ +@property (nonatomic, strong) NSNumber *number; + +/** + @brief 0-indexed offset for paging. + */ +@property (nonatomic, strong) NSNumber *offset; + +/** + @brief Return the Nth 1-indexed page of tags. Takes precedence over the offset parameter. + @attention Not supported in XML-RPC. + */ +@property (nonatomic, strong) NSNumber *page; + +/** + @brief Return the taxonomies in ascending or descending order. Defaults YES via the API. + */ +@property (nonatomic, assign) RemoteTaxonomyPagingResultsOrder order; + +/** + @brief Return the taxonomies ordering by name or associated count. + */ +@property (nonatomic, assign) RemoteTaxonomyPagingResultsOrdering orderBy; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.m b/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.m new file mode 100644 index 000000000000..b795c5f7319f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteTaxonomyPaging.m @@ -0,0 +1,5 @@ +#import "RemoteTaxonomyPaging.h" + +@implementation RemoteTaxonomyPaging + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.h b/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.h new file mode 100644 index 000000000000..c067d9adb18d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.h @@ -0,0 +1,26 @@ +#import + +@interface RemoteTheme : NSObject + +@property (nonatomic, assign) BOOL active; +@property (nonatomic, strong) NSString *type; +@property (nonatomic, strong) NSString *author; +@property (nonatomic, strong) NSString *authorUrl; +@property (nonatomic, strong) NSString *desc; +@property (nonatomic, strong) NSString *demoUrl; +@property (nonatomic, strong) NSString *themeUrl; +@property (nonatomic, strong) NSString *downloadUrl; +@property (nonatomic, strong) NSDate *launchDate; +@property (nonatomic, strong) NSString *name; +@property (nonatomic, assign) NSInteger order; +@property (nonatomic, strong) NSNumber *popularityRank; +@property (nonatomic, strong) NSString *previewUrl; +@property (nonatomic, strong) NSString *price; +@property (nonatomic, strong) NSNumber *purchased; +@property (nonatomic, strong) NSString *screenshotUrl; +@property (nonatomic, strong) NSString *stylesheet; +@property (nonatomic, strong) NSString *themeId; +@property (nonatomic, strong) NSNumber *trendingRank; +@property (nonatomic, strong) NSString *version; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.m b/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.m new file mode 100644 index 000000000000..5b6c2e83ef56 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteTheme.m @@ -0,0 +1,5 @@ +#import "RemoteTheme.h" + +@implementation RemoteTheme + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteUser+Likes.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteUser+Likes.swift new file mode 100644 index 000000000000..299fb6edf152 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteUser+Likes.swift @@ -0,0 +1,63 @@ +import Foundation + +@objc public class RemoteLikeUser: RemoteUser { + @objc public var bio: String? + @objc public var dateLiked: String? + @objc public var likedSiteID: NSNumber? + @objc public var likedPostID: NSNumber? + @objc public var likedCommentID: NSNumber? + @objc public var preferredBlog: RemoteLikeUserPreferredBlog? + + @objc public init(dictionary: [String: Any], postID: NSNumber, siteID: NSNumber) { + super.init() + setValuesFor(dictionary: dictionary) + likedPostID = postID + likedSiteID = siteID + } + + @objc public init(dictionary: [String: Any], commentID: NSNumber, siteID: NSNumber) { + super.init() + setValuesFor(dictionary: dictionary) + likedCommentID = commentID + likedSiteID = siteID + } + + private func setValuesFor(dictionary: [String: Any]) { + userID = dictionary["ID"] as? NSNumber + username = dictionary["login"] as? String + displayName = dictionary["name"] as? String + primaryBlogID = dictionary["site_ID"] as? NSNumber + avatarURL = dictionary["avatar_URL"] as? String + bio = dictionary["bio"] as? String + dateLiked = dictionary["date_liked"] as? String + + preferredBlog = { + if let preferredBlogDict = dictionary["preferred_blog"] as? [String: Any] { + return RemoteLikeUserPreferredBlog.init(dictionary: preferredBlogDict) + } + return nil + }() + } + +} + +@objc public class RemoteLikeUserPreferredBlog: NSObject { + @objc public var blogUrl: String + @objc public var blogName: String + @objc public var iconUrl: String + @objc public var blogID: NSNumber? + + public init(dictionary: [String: Any]) { + blogUrl = dictionary["url"] as? String ?? "" + blogName = dictionary["name"] as? String ?? "" + blogID = dictionary["id"] as? NSNumber ?? nil + + iconUrl = { + if let iconInfo = dictionary["icon"] as? [String: Any], + let iconImg = iconInfo["img"] as? String { + return iconImg + } + return "" + }() + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteUser.h b/WordPressKit/Sources/WordPressKit/Models/RemoteUser.h new file mode 100644 index 000000000000..0145fa6c2df7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteUser.h @@ -0,0 +1,15 @@ +#import + +@interface RemoteUser : NSObject + +@property (nonatomic, strong) NSNumber *userID; +@property (nonatomic, strong) NSString *username; +@property (nonatomic, strong) NSString *email; +@property (nonatomic, strong) NSString *displayName; +@property (nonatomic, strong) NSNumber *primaryBlogID; +@property (nonatomic, strong) NSString *avatarURL; +@property (nonatomic, strong) NSDate *dateCreated; +@property (nonatomic, assign) BOOL emailVerified; +@property (nonatomic, strong) NSNumber *linkedUserID; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteUser.m b/WordPressKit/Sources/WordPressKit/Models/RemoteUser.m new file mode 100644 index 000000000000..751d1e025b82 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteUser.m @@ -0,0 +1,5 @@ +#import "RemoteUser.h" + +@implementation RemoteUser + +@end diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteVideoPressVideo.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteVideoPressVideo.swift new file mode 100644 index 000000000000..7613136a0c3e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteVideoPressVideo.swift @@ -0,0 +1,100 @@ +import Foundation + +/// This enum matches the privacy setting constants defined in Jetpack: +/// https://github.com/Automattic/jetpack/blob/a2ccfb7978184e306211292a66ed49dcf38a517f/projects/packages/videopress/src/utility-functions.php#L13-L17 +@objc public enum VideoPressPrivacySetting: Int, Encodable { + case isPublic = 0 + case isPrivate = 1 + case siteDefault = 2 +} + +@objcMembers public class RemoteVideoPressVideo: NSObject, Encodable { + + /// The following properties match the response parameters from the `videos` endpoint: + /// https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/ + /// + /// However, it's missing the following parameters that could be added in the future if needed: + /// - files + /// - file_url_base + /// - upload_date + /// - files_status + /// - subtitles + public var id: String + public var title: String? + public var videoDescription: String? + public var width: Int? + public var height: Int? + public var duration: Int? + public var displayEmbed: Bool? + public var allowDownload: Bool? + public var rating: String? + public var privacySetting: VideoPressPrivacySetting = .siteDefault + public var posterURL: URL? + public var originalURL: URL? + public var watermarkURL: URL? + public var bgColor: String? + public var blogId: Int? + public var postId: Int? + public var finished: Bool? + + public var token: String? + + enum CodingKeys: String, CodingKey { + case id, title, videoDescription = "description", width, height, duration, displayEmbed, allowDownload, rating, privacySetting, posterURL, originalURL, watermarkURL, bgColor, blogId, postId, finished, token + } + + public init(dictionary metadataDict: NSDictionary, id: String) { + self.id = id + + title = metadataDict.string(forKey: "title") + videoDescription = metadataDict.string(forKey: "description") + width = metadataDict.number(forKey: "width")?.intValue + height = metadataDict.number(forKey: "height")?.intValue + duration = metadataDict.number(forKey: "duration")?.intValue + displayEmbed = metadataDict.object(forKey: "display_embed") as? Bool + allowDownload = metadataDict.object(forKey: "allow_download") as? Bool + rating = metadataDict.string(forKey: "rating") + if let privacySettingValue = metadataDict.number(forKey: "privacy_setting")?.intValue, let privacySettingEnum = VideoPressPrivacySetting.init(rawValue: privacySettingValue) { + privacySetting = privacySettingEnum + } + if let poster = metadataDict.string(forKey: "poster") { + posterURL = URL(string: poster) + } + if let original = metadataDict.string(forKey: "original") { + originalURL = URL(string: original) + } + if let watermark = metadataDict.string(forKey: "watermark") { + watermarkURL = URL(string: watermark) + } + bgColor = metadataDict.string(forKey: "bg_color") + blogId = metadataDict.number(forKey: "blog_id")?.intValue + postId = metadataDict.number(forKey: "post_id")?.intValue + finished = metadataDict.object(forKey: "finished") as? Bool + } + + /// Returns the specified URL adding the token as a query parameter, which is required to play private videos. + /// - Parameters: + /// - url: URL to include the token. + /// + /// - Returns: The specified URL with the token as a query parameter. It will return `nil` if the token is not present. + @objc(getURLWithToken:) + public func getURLWithToken(url: URL) -> URL? { + guard let token, var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return nil + } + let metadataTokenParam = URLQueryItem(name: "metadata_token", value: token) + urlComponents.queryItems = (urlComponents.queryItems ?? []) + [metadataTokenParam] + return urlComponents.url + } + + public func asDictionary() -> [String: Any] { + guard + let data = try? JSONEncoder().encode(self), + let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] + else { + assertionFailure("Encoding of RemoteVideoPressVideo failed") + return [String: Any]() + } + return dictionary + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/RemoteWpcomPlan.swift b/WordPressKit/Sources/WordPressKit/Models/RemoteWpcomPlan.swift new file mode 100644 index 000000000000..75dbee2135f1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/RemoteWpcomPlan.swift @@ -0,0 +1,49 @@ +import Foundation + +public struct RemoteWpcomPlan { + // A commma separated list of groups to which the plan belongs. + public let groups: String + // A comma separated list of plan_ids described by the plan description, e.g. 1 year and 2 year plans. + public let products: String + // The full name of the plan. + public let name: String + // The shortened name of the plan. + public let shortname: String + // The plan's tagline. + public let tagline: String + // A description of the plan. + public let description: String + // A comma separated list of slugs for the plan's features. + public let features: String + // An icon representing the plan. + public let icon: String + // The plan priority in Zendesk + public let supportPriority: Int + // The name of the plan in Zendesk + public let supportName: String + // Non localized version of the shortened name + public let nonLocalizedShortname: String +} + +public struct RemotePlanGroup { + // A text slug identifying the group. + public let slug: String + // The name of the group. + public let name: String +} + +public struct RemotePlanFeature { + // A text slug identifying the plan feature. + public let slug: String + // The name/title of the feature. + public let title: String + // A description of the feature. + public let description: String + // Deprecated. An icon associeated with the feature. + public let iconURL: URL? +} + +public struct RemotePlanSimpleDescription { + public let planID: Int + public let name: String +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteDiff.swift b/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteDiff.swift new file mode 100644 index 000000000000..b93f8543755c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteDiff.swift @@ -0,0 +1,110 @@ +import Foundation + +/// RemoteDiff model +public struct RemoteDiff: Codable { + /// Revision id from the content has been changed + public var fromRevisionId: Int + + /// Current revision id + public var toRevisionId: Int + + /// Model for the diff values + public var values: RemoteDiffValues + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case fromRevisionId = "from" + case toRevisionId = "to" + case values = "diff" + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + fromRevisionId = (try? data.decode(Int.self, forKey: .fromRevisionId)) ?? 0 + toRevisionId = (try? data.decode(Int.self, forKey: .toRevisionId)) ?? 0 + values = try data.decode(RemoteDiffValues.self, forKey: .values) + } +} + +/// RemoteDiffValues model +public struct RemoteDiffValues: Codable { + /// Model for the diff totals operations + public var totals: RemoteDiffTotals? + + /// Title diffs array + public var titleDiffs: [RemoteDiffValue] + + /// Content diffs array + public var contentDiffs: [RemoteDiffValue] + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case titleDiffs = "post_title" + case contentDiffs = "post_content" + case totals + } +} + +/// RemoteDiffTotals model +public struct RemoteDiffTotals: Codable { + /// Total of additional operations + public var totalAdditions: Int + + /// Total of deletions operations + public var totalDeletions: Int + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case totalAdditions = "add" + case totalDeletions = "del" + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + totalAdditions = (try? data.decode(Int.self, forKey: .totalAdditions)) ?? 0 + totalDeletions = (try? data.decode(Int.self, forKey: .totalDeletions)) ?? 0 + } +} + +/// RemoteDiffOperation enumeration +/// +/// - add: Addition +/// - copy: Copy +/// - del: Deletion +/// - unknown: Default value +public enum RemoteDiffOperation: String, Codable { + case add + case copy + case del + case unknown +} + +/// DiffValue +public struct RemoteDiffValue: Codable { + /// Diff operation + public var operation: RemoteDiffOperation + + /// Diff value + public var value: String? + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case operation = "op" + case value + } + + // MARK: - Decode protocol + + public init(from decoder: Decoder) throws { + let data = try decoder.container(keyedBy: CodingKeys.self) + + operation = (try? data.decode(RemoteDiffOperation.self, forKey: .operation)) ?? .unknown + value = try? data.decode(String.self, forKey: .value) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteRevision.swift b/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteRevision.swift new file mode 100644 index 000000000000..c57171aeba3e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Revisions/RemoteRevision.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Revision model +/// +public struct RemoteRevision: Codable { + /// Revision id + public var id: Int + + /// Optional post content + public var postContent: String? + + /// Optional post excerpt + public var postExcerpt: String? + + /// Optional post title + public var postTitle: String? + + /// Optional post date + public var postDateGmt: String? + + /// Optional post modified date + public var postModifiedGmt: String? + + /// Optional post author id + public var postAuthorId: String? + + /// Optional revision diff + public var diff: RemoteDiff? + + /// Mapping keys + private enum CodingKeys: String, CodingKey { + case id = "id" + case postContent = "post_content" + case postExcerpt = "post_excerpt" + case postTitle = "post_title" + case postDateGmt = "post_date_gmt" + case postModifiedGmt = "post_modified_gmt" + case postAuthorId = "post_author" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/SessionDetails.swift b/WordPressKit/Sources/WordPressKit/Models/SessionDetails.swift new file mode 100644 index 000000000000..dc36e0fbda87 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/SessionDetails.swift @@ -0,0 +1,35 @@ +public struct SessionDetails { + let deviceId: String + let platform: String + let buildNumber: String + let marketingVersion: String + let identifier: String + let osVersion: String +} + +extension SessionDetails: Encodable { + + enum CodingKeys: String, CodingKey { + case deviceId = "device_id" + case platform = "platform" + case buildNumber = "build_number" + case marketingVersion = "marketing_version" + case identifier = "identifier" + case osVersion = "os_version" + } + + init(deviceId: String, bundle: Bundle = .main) { + self.deviceId = deviceId + self.platform = "ios" + self.buildNumber = bundle.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + self.marketingVersion = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + self.identifier = bundle.bundleIdentifier ?? "Unknown" + self.osVersion = UIDevice.current.systemVersion + } + + func dictionaryRepresentation() throws -> [String: AnyObject]? { + let encoder = JSONEncoder() + let data = try encoder.encode(self) + return try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Emails/StatsEmailsSummaryData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Emails/StatsEmailsSummaryData.swift new file mode 100644 index 000000000000..fe9fb03a027d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Emails/StatsEmailsSummaryData.swift @@ -0,0 +1,94 @@ +import Foundation +import WordPressShared + +public struct StatsEmailsSummaryData: Decodable, Equatable { + public let posts: [Post] + + public init(posts: [Post]) { + self.posts = posts + } + + private enum CodingKeys: String, CodingKey { + case posts = "posts" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + posts = try container.decode([Post].self, forKey: .posts) + } + + public struct Post: Codable, Equatable { + public let id: Int + public let link: URL + public let date: Date + public let title: String + public let type: PostType + public let opens: Int + public let clicks: Int + + public init(id: Int, link: URL, date: Date, title: String, type: PostType, opens: Int, clicks: Int) { + self.id = id + self.link = link + self.date = date + self.title = title + self.type = type + self.opens = opens + self.clicks = clicks + } + + public enum PostType: String, Codable { + case post = "post" + } + + private enum CodingKeys: String, CodingKey { + case id = "id" + case link = "href" + case date = "date" + case title = "title" + case type = "type" + case opens = "opens" + case clicks = "clicks" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(Int.self, forKey: .id) + link = try container.decode(URL.self, forKey: .link) + title = (try? container.decodeIfPresent(String.self, forKey: .title)) ?? "" + type = (try? container.decodeIfPresent(PostType.self, forKey: .type)) ?? .post + opens = (try? container.decodeIfPresent(Int.self, forKey: .opens)) ?? 0 + clicks = (try? container.decodeIfPresent(Int.self, forKey: .clicks)) ?? 0 + self.date = try container.decode(Date.self, forKey: .date) + } + } +} + +extension StatsEmailsSummaryData { + public static var pathComponent: String { + return "stats/emails/summary" + } + + public init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder.apiDecoder + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } + + public static func queryProperties(quantity: Int, sortField: SortField, sortOrder: SortOrder) -> [String: String] { + return ["quantity": String(quantity), "sort_field": sortField.rawValue, "sort_order": sortOrder.rawValue] + } + + public enum SortField: String { + case opens = "opens" + case postId = "post_id" + } + + public enum SortOrder: String { + case descending = "desc" + case ascending = "ASC" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllAnnualInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllAnnualInsight.swift new file mode 100644 index 000000000000..4c40baff770b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllAnnualInsight.swift @@ -0,0 +1,84 @@ +public struct StatsAllAnnualInsight: Codable { + public let allAnnualInsights: [StatsAnnualInsight] + + public init(allAnnualInsights: [StatsAnnualInsight]) { + self.allAnnualInsights = allAnnualInsights + } + + private enum CodingKeys: String, CodingKey { + case allAnnualInsights = "years" + } +} + +public struct StatsAnnualInsight: Codable { + public let year: Int + public let totalPostsCount: Int + public let totalWordsCount: Int + public let averageWordsCount: Double + public let totalLikesCount: Int + public let averageLikesCount: Double + public let totalCommentsCount: Int + public let averageCommentsCount: Double + public let totalImagesCount: Int + public let averageImagesCount: Double + + public init(year: Int, + totalPostsCount: Int, + totalWordsCount: Int, + averageWordsCount: Double, + totalLikesCount: Int, + averageLikesCount: Double, + totalCommentsCount: Int, + averageCommentsCount: Double, + totalImagesCount: Int, + averageImagesCount: Double) { + self.year = year + self.totalPostsCount = totalPostsCount + self.totalWordsCount = totalWordsCount + self.averageWordsCount = averageWordsCount + self.totalLikesCount = totalLikesCount + self.averageLikesCount = averageLikesCount + self.totalCommentsCount = totalCommentsCount + self.averageCommentsCount = averageCommentsCount + self.totalImagesCount = totalImagesCount + self.averageImagesCount = averageImagesCount + } + + private enum CodingKeys: String, CodingKey { + case year + case totalPostsCount = "total_posts" + case totalWordsCount = "total_words" + case averageWordsCount = "avg_words" + case totalLikesCount = "total_likes" + case averageLikesCount = "avg_likes" + case totalCommentsCount = "total_comments" + case averageCommentsCount = "avg_comments" + case totalImagesCount = "total_images" + case averageImagesCount = "avg_images" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if let year = Int(try container.decode(String.self, forKey: .year)) { + self.year = year + } else { + throw DecodingError.dataCorruptedError(forKey: .year, in: container, debugDescription: "Year cannot be parsed into number.") + } + totalPostsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalPostsCount)) ?? 0 + totalWordsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalWordsCount)) ?? 0 + averageWordsCount = (try? container.decodeIfPresent(Double.self, forKey: .averageWordsCount)) ?? 0 + totalLikesCount = (try? container.decodeIfPresent(Int.self, forKey: .totalLikesCount)) ?? 0 + averageLikesCount = (try? container.decodeIfPresent(Double.self, forKey: .averageLikesCount)) ?? 0 + totalCommentsCount = (try? container.decodeIfPresent(Int.self, forKey: .totalCommentsCount)) ?? 0 + averageCommentsCount = (try? container.decodeIfPresent(Double.self, forKey: .averageCommentsCount)) ?? 0 + totalImagesCount = (try? container.decodeIfPresent(Int.self, forKey: .totalImagesCount)) ?? 0 + averageImagesCount = (try? container.decodeIfPresent(Double.self, forKey: .averageImagesCount)) ?? 0 + } +} + +extension StatsAllAnnualInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/insights" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllTimesInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllTimesInsight.swift new file mode 100644 index 000000000000..13f2f85f9243 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAllTimesInsight.swift @@ -0,0 +1,53 @@ +public struct StatsAllTimesInsight: Codable { + public let postsCount: Int + public let viewsCount: Int + public let bestViewsDay: Date + public let visitorsCount: Int + public let bestViewsPerDayCount: Int + + public init(postsCount: Int, + viewsCount: Int, + bestViewsDay: Date, + visitorsCount: Int, + bestViewsPerDayCount: Int) { + self.postsCount = postsCount + self.viewsCount = viewsCount + self.bestViewsDay = bestViewsDay + self.visitorsCount = visitorsCount + self.bestViewsPerDayCount = bestViewsPerDayCount + } + + private enum CodingKeys: String, CodingKey { + case postsCount = "posts" + case viewsCount = "views" + case bestViewsDay = "views_best_day" + case visitorsCount = "visitors" + case bestViewsPerDayCount = "views_best_day_total" + } + + private enum RootKeys: String, CodingKey { + case stats + } +} + +extension StatsAllTimesInsight: StatsInsightData { + public init (from decoder: Decoder) throws { + let rootContainer = try decoder.container(keyedBy: RootKeys.self) + let container = try rootContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: .stats) + + self.postsCount = (try? container.decodeIfPresent(Int.self, forKey: .postsCount)) ?? 0 + self.bestViewsPerDayCount = (try? container.decodeIfPresent(Int.self, forKey: .bestViewsPerDayCount)) ?? 0 + self.visitorsCount = (try? container.decodeIfPresent(Int.self, forKey: .visitorsCount)) ?? 0 + + self.viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + let bestViewsDayString = try container.decodeIfPresent(String.self, forKey: .bestViewsDay) ?? "" + self.bestViewsDay = StatsAllTimesInsight.dateFormatter.date(from: bestViewsDayString) ?? Date() + } + + // MARK: - + private static var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAnnualAndMostPopularTimeInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAnnualAndMostPopularTimeInsight.swift new file mode 100644 index 000000000000..628104fd8c0a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsAnnualAndMostPopularTimeInsight.swift @@ -0,0 +1,91 @@ +public struct StatsAnnualAndMostPopularTimeInsight: Codable { + /// - A `DateComponents` object with one field populated: `weekday`. + public let mostPopularDayOfWeek: DateComponents + public let mostPopularDayOfWeekPercentage: Int + + /// - A `DateComponents` object with one field populated: `hour`. + public let mostPopularHour: DateComponents + public let mostPopularHourPercentage: Int + public let years: [Year]? + + private enum CodingKeys: String, CodingKey { + case mostPopularHour = "highest_hour" + case mostPopularHourPercentage = "highest_hour_percent" + case mostPopularDayOfWeek = "highest_day_of_week" + case mostPopularDayOfWeekPercentage = "highest_day_percent" + case years + } + + public struct Year: Codable { + public let year: String + public let totalPosts: Int + public let totalWords: Int + public let averageWords: Double + public let totalLikes: Int + public let averageLikes: Double + public let totalComments: Int + public let averageComments: Double + public let totalImages: Int + public let averageImages: Double + + private enum CodingKeys: String, CodingKey { + case year + case totalPosts = "total_posts" + case totalWords = "total_words" + case averageWords = "avg_words" + case totalLikes = "total_likes" + case averageLikes = "avg_likes" + case totalComments = "total_comments" + case averageComments = "avg_comments" + case totalImages = "total_images" + case averageImages = "avg_images" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + year = try container.decode(String.self, forKey: .year) + totalPosts = (try? container.decodeIfPresent(Int.self, forKey: .totalPosts)) ?? 0 + totalWords = (try? container.decode(Int.self, forKey: .totalWords)) ?? 0 + averageWords = (try? container.decode(Double.self, forKey: .averageWords)) ?? 0 + totalLikes = (try? container.decode(Int.self, forKey: .totalLikes)) ?? 0 + averageLikes = (try? container.decode(Double.self, forKey: .averageLikes)) ?? 0 + totalComments = (try? container.decode(Int.self, forKey: .totalComments)) ?? 0 + averageComments = (try? container.decode(Double.self, forKey: .averageComments)) ?? 0 + totalImages = (try? container.decode(Int.self, forKey: .totalImages)) ?? 0 + averageImages = (try? container.decode(Double.self, forKey: .averageImages)) ?? 0 + } + } +} + +extension StatsAnnualAndMostPopularTimeInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/insights" + } +} + +extension StatsAnnualAndMostPopularTimeInsight { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let years = try container.decodeIfPresent([Year].self, forKey: .years) + let highestHour = try container.decode(Int.self, forKey: .mostPopularHour) + let highestHourPercentageValue = try container.decode(Double.self, forKey: .mostPopularHourPercentage) + let highestDayOfWeek = try container.decode(Int.self, forKey: .mostPopularDayOfWeek) + let highestDayOfWeekPercentageValue = try container.decode(Double.self, forKey: .mostPopularDayOfWeekPercentage) + + let mappedWeekday: ((Int) -> Int) = { + // iOS Calendar system is `1-based` and uses Sunday as the first day of the week. + // The data returned from WP.com is `0-based` and uses Monday as the first day of the week. + // This maps the WP.com data to iOS format. + return $0 == 6 ? 0 : $0 + 2 + } + + let weekDayComponent = DateComponents(weekday: mappedWeekday(highestDayOfWeek)) + let hourComponents = DateComponents(hour: highestHour) + + self.mostPopularDayOfWeek = weekDayComponent + self.mostPopularDayOfWeekPercentage = Int(highestDayOfWeekPercentageValue.rounded()) + self.mostPopularHour = hourComponents + self.mostPopularHourPercentage = Int(highestHourPercentageValue.rounded()) + self.years = years + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsCommentsInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsCommentsInsight.swift new file mode 100644 index 000000000000..d18f58563052 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsCommentsInsight.swift @@ -0,0 +1,117 @@ +public struct StatsCommentsInsight: Codable { + public let topPosts: [StatsTopCommentsPost] + public let topAuthors: [StatsTopCommentsAuthor] + + public init(topPosts: [StatsTopCommentsPost], + topAuthors: [StatsTopCommentsAuthor]) { + self.topPosts = topPosts + self.topAuthors = topAuthors + } + + private enum CodingKeys: String, CodingKey { + case topPosts = "posts" + case topAuthors = "authors" + } +} + +extension StatsCommentsInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/comments" + } +} + +public struct StatsTopCommentsAuthor: Codable { + public let name: String + public let commentCount: Int + public let iconURL: URL? + + public init(name: String, + commentCount: Int, + iconURL: URL?) { + self.name = name + self.commentCount = commentCount + self.iconURL = iconURL + } + + private enum CodingKeys: String, CodingKey { + case name + case commentCount = "comments" + case iconURL = "gravatar" + } +} + +public struct StatsTopCommentsPost: Codable { + public let name: String + public let postID: String + public let commentCount: Int + public let postURL: URL? + + public init(name: String, + postID: String, + commentCount: Int, + postURL: URL?) { + self.name = name + self.postID = postID + self.commentCount = commentCount + self.postURL = postURL + } + + private enum CodingKeys: String, CodingKey { + case name + case postID = "id" + case commentCount = "comments" + case postURL = "link" + } +} + +private extension StatsTopCommentsAuthor { + init(name: String, avatar: String?, commentCount: Int) { + let url: URL? + + if let avatar, var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" // to get a properly-sized avatar. + url = components.url + } else { + url = nil + } + + self.name = name + self.commentCount = commentCount + self.iconURL = url + } +} + +public extension StatsTopCommentsAuthor { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let commentCount: Int + if let comments = try? container.decodeIfPresent(String.self, forKey: .commentCount) { + commentCount = Int(comments) ?? 0 + } else { + commentCount = 0 + } + let iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL) + + self.init(name: name, avatar: iconURL, commentCount: commentCount) + } +} + +public extension StatsTopCommentsPost { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let postID = try container.decode(String.self, forKey: .postID) + let commentCount: Int + if let comments = try? container.decodeIfPresent(String.self, forKey: .commentCount) { + commentCount = Int(comments) ?? 0 + } else { + commentCount = 0 + } + let postURL = try container.decodeIfPresent(URL.self, forKey: .postURL) + + self.init(name: name, postID: postID, commentCount: commentCount, postURL: postURL) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsDotComFollowersInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsDotComFollowersInsight.swift new file mode 100644 index 000000000000..2c3d304c6521 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsDotComFollowersInsight.swift @@ -0,0 +1,93 @@ +public struct StatsDotComFollowersInsight: Codable { + public let dotComFollowersCount: Int + public let topDotComFollowers: [StatsFollower] + + public init (dotComFollowersCount: Int, + topDotComFollowers: [StatsFollower]) { + self.dotComFollowersCount = dotComFollowersCount + self.topDotComFollowers = topDotComFollowers + } + + private enum CodingKeys: String, CodingKey { + case dotComFollowersCount = "total_wpcom" + case topDotComFollowers = "subscribers" + } +} + +extension StatsDotComFollowersInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["type": "wpcom", + "max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/followers" + } + + fileprivate static let dateFormatter = ISO8601DateFormatter() +} + +public struct StatsFollower: Codable, Equatable { + public let id: String? + public let name: String + public let subscribedDate: Date + public let avatarURL: URL? + + public init(name: String, + subscribedDate: Date, + avatarURL: URL?, + id: String? = nil) { + self.name = name + self.subscribedDate = subscribedDate + self.avatarURL = avatarURL + self.id = id + } + + private enum CodingKeys: String, CodingKey { + case id = "ID" + case name = "label" + case subscribedDate = "date_subscribed" + case avatarURL = "avatar" + } +} + +extension StatsFollower { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decode(String.self, forKey: .name) + if let id = try? container.decodeIfPresent(Int.self, forKey: .id) { + self.id = "\(id)" + } else if let id = try? container.decodeIfPresent(String.self, forKey: .id) { + self.id = id + } else { + self.id = nil + } + + let avatar = try? container.decodeIfPresent(String.self, forKey: .avatarURL) + if let avatar, var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" // to get a properly-sized avatar. + self.avatarURL = components.url + } else { + self.avatarURL = nil + } + + let dateString = try container.decode(String.self, forKey: .subscribedDate) + if let date = StatsDotComFollowersInsight.dateFormatter.date(from: dateString) { + self.subscribedDate = date + } else { + throw DecodingError.dataCorruptedError(forKey: .subscribedDate, in: container, debugDescription: "Date string does not match format expected by formatter.") + } + } + + init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(StatsFollower.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsEmailFollowersInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsEmailFollowersInsight.swift new file mode 100644 index 000000000000..49dc7be2dee0 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsEmailFollowersInsight.swift @@ -0,0 +1,28 @@ +public struct StatsEmailFollowersInsight: Codable { + public let emailFollowersCount: Int + public let topEmailFollowers: [StatsFollower] + + public init(emailFollowersCount: Int, + topEmailFollowers: [StatsFollower]) { + self.emailFollowersCount = emailFollowersCount + self.topEmailFollowers = topEmailFollowers + } + + private enum CodingKeys: String, CodingKey { + case emailFollowersCount = "total_email" + case topEmailFollowers = "subscribers" + } +} + +extension StatsEmailFollowersInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["type": "email", + "max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/followers" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsLastPostInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsLastPostInsight.swift new file mode 100644 index 000000000000..6241a72d75f9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsLastPostInsight.swift @@ -0,0 +1,96 @@ +public struct StatsLastPostInsight: Equatable, Decodable { + public let title: String + public let url: URL + public let publishedDate: Date + public let likesCount: Int + public let commentsCount: Int + public private(set) var viewsCount: Int = 0 + public let postID: Int + public let featuredImageURL: URL? + + public init(title: String, + url: URL, + publishedDate: Date, + likesCount: Int, + commentsCount: Int, + viewsCount: Int, + postID: Int, + featuredImageURL: URL?) { + self.title = title + self.url = url + self.publishedDate = publishedDate + self.likesCount = likesCount + self.commentsCount = commentsCount + self.viewsCount = viewsCount + self.postID = postID + self.featuredImageURL = featuredImageURL + } +} + +extension StatsLastPostInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["order_by": "date", + "number": "1", + "type": "post", + "fields": "ID, title, URL, discussion, like_count, date, featured_image"] + } + + public static var pathComponent: String { + return "posts/" + } + + public init?(jsonDictionary: [String: AnyObject]) { + self.init(jsonDictionary: jsonDictionary, views: 0) + } + + // MARK: - + + private static let dateFormatter = ISO8601DateFormatter() + + public init?(jsonDictionary: [String: AnyObject], views: Int) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(StatsLastPostInsight.self, from: jsonData) + self.viewsCount = views + } catch { + return nil + } + } +} + +extension StatsLastPostInsight { + private enum CodingKeys: String, CodingKey { + case title + case url = "URL" + case publishedDate = "date" + case likesCount = "like_count" + case commentsCount + case postID = "ID" + case featuredImageURL = "featured_image" + case discussion + } + + private enum DiscussionKeys: String, CodingKey { + case commentsCount = "comment_count" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + title = try container.decode(String.self, forKey: .title).trimmingCharacters(in: .whitespaces).stringByDecodingXMLCharacters() + url = try container.decode(URL.self, forKey: .url) + let dateString = try container.decode(String.self, forKey: .publishedDate) + guard let date = StatsLastPostInsight.dateFormatter.date(from: dateString) else { + throw DecodingError.dataCorruptedError(forKey: .publishedDate, in: container, debugDescription: "Date string does not match format expected by formatter.") + } + publishedDate = date + likesCount = (try? container.decodeIfPresent(Int.self, forKey: .likesCount)) ?? 0 + postID = try container.decode(Int.self, forKey: .postID) + featuredImageURL = try? container.decodeIfPresent(URL.self, forKey: .featuredImageURL) + + let discussionContainer = try container.nestedContainer(keyedBy: DiscussionKeys.self, forKey: .discussion) + commentsCount = (try? discussionContainer.decodeIfPresent(Int.self, forKey: .commentsCount)) ?? 0 + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPostingStreakInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPostingStreakInsight.swift new file mode 100644 index 000000000000..035ce31b2891 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPostingStreakInsight.swift @@ -0,0 +1,148 @@ +public struct StatsPostingStreakInsight: Equatable, Codable { + public let streaks: PostingStreaks + public let postingEvents: [PostingStreakEvent] + + public var currentStreakStart: Date? { + streaks.current?.start + } + + public var currentStreakEnd: Date? { + streaks.current?.end + } + public var currentStreakLength: Int? { + streaks.current?.length + } + + public var longestStreakStart: Date? { + streaks.long?.start ?? currentStreakStart + } + public var longestStreakEnd: Date? { + streaks.long?.end ?? currentStreakEnd + } + + public var longestStreakLength: Int? { + streaks.long?.length ?? currentStreakLength + } + + private enum CodingKeys: String, CodingKey { + case streaks = "streak" + case postingEvents = "data" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.streaks = try container.decode(PostingStreaks.self, forKey: .streaks) + let postsData = (try? container.decodeIfPresent([String: Int].self, forKey: .postingEvents)) ?? [:] + + let postingDates = postsData.keys + .compactMap { Double($0) } + .map { Date(timeIntervalSince1970: $0) } + .map { Calendar.autoupdatingCurrent.startOfDay(for: $0) } + + if postingDates.isEmpty { + self.postingEvents = [] + } else { + let countedPosts = NSCountedSet(array: postingDates) + self.postingEvents = countedPosts.compactMap { value in + if let date = value as? Date { + return PostingStreakEvent(date: date, postCount: countedPosts.count(for: value)) + } else { + return nil + } + } + } + } +} + +public struct PostingStreakEvent: Equatable, Codable { + public let date: Date + public let postCount: Int + + public init(date: Date, postCount: Int) { + self.date = date + self.postCount = postCount + } +} + +public struct PostingStreaks: Equatable, Codable { + public let long: PostingStreak? + public let current: PostingStreak? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.long = try? container.decodeIfPresent(PostingStreak.self, forKey: .long) + self.current = try? container.decodeIfPresent(PostingStreak.self, forKey: .current) + } +} + +public struct PostingStreak: Equatable, Codable { + public let start: Date + public let end: Date + public let length: Int + + private enum CodingKeys: String, CodingKey { + case start + case end + case length + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let startValue = try container.decode(String.self, forKey: .start) + if let start = StatsPostingStreakInsight.dateFormatter.date(from: startValue) { + self.start = start + } else { + throw DecodingError.dataCorruptedError(forKey: .start, in: container, debugDescription: "Start date string doesn't match expected format") + } + + let endValue = try container.decode(String.self, forKey: .end) + if let end = StatsPostingStreakInsight.dateFormatter.date(from: endValue) { + self.end = end + } else { + throw DecodingError.dataCorruptedError(forKey: .end, in: container, debugDescription: "End date string doesn't match expected format") + } + + length = try container.decodeIfPresent(Int.self, forKey: .length) ?? 0 + } +} + +extension StatsPostingStreakInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/streak" + } + + // Some heavy-traffic sites can have A LOT of posts and the default query parameters wouldn't + // return all the relevant streak data, so we manualy override the `max` and `startDate``/endDate` + // parameters to hopefully get all. + public static var queryProperties: [String: String] { + let today = Date() + + let numberOfDaysInCurrentMonth = Calendar.autoupdatingCurrent.range(of: .day, in: .month, for: today) + + guard + let firstDayIndex = numberOfDaysInCurrentMonth?.first, + let lastDayIndex = numberOfDaysInCurrentMonth?.last, + let lastDayOfMonth = Calendar.autoupdatingCurrent.date(bySetting: .day, value: lastDayIndex, of: today), + let firstDayOfMonth = Calendar.autoupdatingCurrent.date(bySetting: .day, value: firstDayIndex, of: today), + let yearAgo = Calendar.autoupdatingCurrent.date(byAdding: .year, value: -1, to: firstDayOfMonth) + else { + return [:] + } + + let firstDayString = self.dateFormatter.string(from: yearAgo) + let lastDayString = self.dateFormatter.string(from: lastDayOfMonth) + + return ["startDate": "\(firstDayString)", + "endDate": "\(lastDayString)", + "max": "5000"] + } + + fileprivate static var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPublicizeInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPublicizeInsight.swift new file mode 100644 index 000000000000..6db08b0c3976 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsPublicizeInsight.swift @@ -0,0 +1,83 @@ +public struct StatsPublicizeInsight: Codable { + public let publicizeServices: [StatsPublicizeService] + + public init(publicizeServices: [StatsPublicizeService]) { + self.publicizeServices = publicizeServices + } + + private enum CodingKeys: String, CodingKey { + case publicizeServices = "services" + } +} + +extension StatsPublicizeInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/publicize" + } +} + +public struct StatsPublicizeService: Codable { + public let name: String + public let followers: Int + public let iconURL: URL? + + public init(name: String, + followers: Int, + iconURL: URL?) { + self.name = name + self.followers = followers + self.iconURL = iconURL + } + + private enum CodingKeys: String, CodingKey { + case name = "service" + case followers + } +} + +private extension StatsPublicizeService { + init(name: String, followers: Int) { + let niceName: String + let icon: URL? + + switch name { + case "facebook": + niceName = "Facebook" + icon = URL(string: "https://secure.gravatar.com/blavatar/2343ec78a04c6ea9d80806345d31fd78?s=60") + case "twitter": + niceName = "Twitter" + icon = URL(string: "https://secure.gravatar.com/blavatar/7905d1c4e12c54933a44d19fcd5f9356?s=60") + case "tumblr": + niceName = "Tumblr" + icon = URL(string: "https://secure.gravatar.com/blavatar/84314f01e87cb656ba5f382d22d85134?s=60") + case "google_plus": + niceName = "Google+" + icon = URL(string: "https://secure.gravatar.com/blavatar/4a4788c1dfc396b1f86355b274cc26b3?s=60") + case "linkedin": + niceName = "LinkedIn" + icon = URL(string: "https://secure.gravatar.com/blavatar/f54db463750940e0e7f7630fe327845e?s=60") + case "path": + niceName = "path" + icon = URL(string: "https://secure.gravatar.com/blavatar/3a03c8ce5bf1271fb3760bb6e79b02c1?s=60") + default: + niceName = name + icon = nil + } + + self.name = niceName + self.followers = followers + self.iconURL = icon + } +} + +public extension StatsPublicizeService { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let name = try container.decode(String.self, forKey: .name) + let followers = (try? container.decodeIfPresent(Int.self, forKey: .followers)) ?? 0 + + self.init(name: name, followers: followers) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTagsAndCategoriesInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTagsAndCategoriesInsight.swift new file mode 100644 index 000000000000..8daaad19e48b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTagsAndCategoriesInsight.swift @@ -0,0 +1,85 @@ +public struct StatsTagsAndCategoriesInsight: Codable { + public let topTagsAndCategories: [StatsTagAndCategory] + + private enum CodingKeys: String, CodingKey { + case topTagsAndCategories = "tags" + } +} + +extension StatsTagsAndCategoriesInsight: StatsInsightData { + public static var pathComponent: String { + return "stats/tags" + } +} + +public struct StatsTagAndCategory: Codable { + public enum Kind: String, Codable { + case tag + case category + case folder + } + + public let name: String + public let kind: Kind + public let url: URL? + public let viewsCount: Int? + public let children: [StatsTagAndCategory] + + private enum CodingKeys: String, CodingKey { + case name + case kind = "type" + case url = "link" + case viewsCount = "views" + case children = "tags" + } + + public init(name: String, kind: Kind, url: URL?, viewsCount: Int?, children: [StatsTagAndCategory]) { + self.name = name + self.kind = kind + self.url = url + self.viewsCount = viewsCount + self.children = children + } +} + +extension StatsTagAndCategory { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let innerTags = try container.decodeIfPresent([StatsTagAndCategory].self, forKey: .children) ?? [] + let viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + + // This gets kinda complicated. The API collects some tags/categories + // into groups, and we have to handle that. + if innerTags.isEmpty { + self.init( + name: try container.decode(String.self, forKey: .name), + kind: try container.decode(Kind.self, forKey: .kind), + url: try container.decodeIfPresent(URL.self, forKey: .url), + viewsCount: nil, + children: [] + ) + } else if innerTags.count == 1, let tag = innerTags.first { + self.init(singleTag: tag, viewsCount: viewsCount) + } else { + let mappedChildren = innerTags.compactMap { StatsTagAndCategory(singleTag: $0) } + let label = mappedChildren.map { $0.name }.joined(separator: ", ") + self.init(name: label, kind: .folder, url: nil, viewsCount: viewsCount, children: mappedChildren) + } + } + + init(singleTag tag: StatsTagAndCategory, viewsCount: Int? = 0) { + let kind: Kind + + switch tag.kind { + case .category: + kind = .category + case .tag: + kind = .tag + default: + kind = .category + } + + self.init(name: tag.name, kind: kind, url: tag.url, viewsCount: viewsCount, children: []) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTodayInsight.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTodayInsight.swift new file mode 100644 index 000000000000..2a271b62df45 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Insights/StatsTodayInsight.swift @@ -0,0 +1,39 @@ +public struct StatsTodayInsight: Codable { + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init(viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int) { + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsTodayInsight: StatsInsightData { + + // MARK: - StatsInsightData Conformance + public static var pathComponent: String { + return "stats/summary" + } + + private enum CodingKeys: String, CodingKey { + case viewsCount = "views" + case visitorsCount = "visitors" + case likesCount = "likes" + case commentsCount = "comments" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + viewsCount = (try? container.decodeIfPresent(Int.self, forKey: .viewsCount)) ?? 0 + visitorsCount = (try? container.decodeIfPresent(Int.self, forKey: .visitorsCount)) ?? 0 + likesCount = (try? container.decodeIfPresent(Int.self, forKey: .likesCount)) ?? 0 + commentsCount = (try? container.decodeIfPresent(Int.self, forKey: .commentsCount)) ?? 0 + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift new file mode 100644 index 000000000000..46d5823d31e1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/StatsPostDetails.swift @@ -0,0 +1,147 @@ +public struct StatsPostDetails: Equatable { + public let fetchedDate: Date + public let totalViewsCount: Int + + public let recentWeeks: [StatsWeeklyBreakdown] + public let dailyAveragesPerMonth: [StatsPostViews] + public let monthlyBreakdown: [StatsPostViews] + public let lastTwoWeeks: [StatsPostViews] +} + +public struct StatsWeeklyBreakdown: Equatable { + public let startDay: DateComponents + public let endDay: DateComponents + + public let totalViewsCount: Int + public let averageViewsCount: Int + public let changePercentage: Double + + public let days: [StatsPostViews] +} + +public struct StatsPostViews: Equatable { + public let period: StatsPeriodUnit + public let date: DateComponents + public let viewsCount: Int +} + +extension StatsPostDetails { + init?(jsonDictionary: [String: AnyObject]) { + guard + let fetchedDateString = jsonDictionary["date"] as? String, + let date = type(of: self).dateFormatter.date(from: fetchedDateString), + let totalViewsCount = jsonDictionary["views"] as? Int, + let monthlyBreakdown = jsonDictionary["years"] as? [String: AnyObject], + let monthlyAverages = jsonDictionary["averages"] as? [String: AnyObject], + let recentWeeks = jsonDictionary["weeks"] as? [[String: AnyObject]], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + self.fetchedDate = date + self.totalViewsCount = totalViewsCount + + // It's very hard to describe the format of this response. I tried to make the parsing + // as nice and readable as possible, but in all honestly it's still pretty nasty. + // If you want to see an example response to see how weird this response is, check out + // `stats-post-details.json`. + self.recentWeeks = StatsPostViews.mapWeeklyBreakdown(jsonDictionary: recentWeeks) + self.monthlyBreakdown = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyBreakdown) + self.dailyAveragesPerMonth = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyAverages) + self.lastTwoWeeks = StatsPostViews.mapDailyData(data: Array(data.suffix(14))) + } + + static var dateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + } +} + +extension StatsPostViews { + static func mapMonthlyBreakdown(jsonDictionary: [String: AnyObject]) -> [StatsPostViews] { + return jsonDictionary.flatMap { yearKey, value -> [StatsPostViews] in + guard + let yearInt = Int(yearKey), + let monthsDict = value as? [String: AnyObject], + let months = monthsDict["months"] as? [String: Int] + else { + return [] + } + + return months.compactMap { monthKey, value in + guard + let month = Int(monthKey) + else { + return nil + } + + return StatsPostViews(period: .month, + date: DateComponents(year: yearInt, month: month), + viewsCount: value) + } + } + } +} + +extension StatsPostViews { + static func mapWeeklyBreakdown(jsonDictionary: [[String: AnyObject]]) -> [StatsWeeklyBreakdown] { + return jsonDictionary.compactMap { + guard + let totalViews = $0["total"] as? Int, + let averageViews = $0["average"] as? Int, + let days = $0["days"] as? [[String: AnyObject]] + else { + return nil + } + + let change = ($0["change"] as? Double) ?? 0.0 + + let mappedDays: [StatsPostViews] = days.compactMap { + guard + let dayString = $0["day"] as? String, + let date = StatsPostDetails.dateFormatter.date(from: dayString), + let viewsCount = $0["count"] as? Int + else { + return nil + } + + return StatsPostViews(period: .day, + date: Calendar.autoupdatingCurrent.dateComponents([.year, .month, .day], from: date), + viewsCount: viewsCount) + } + + guard !mappedDays.isEmpty else { + return nil + } + + return StatsWeeklyBreakdown(startDay: mappedDays.first!.date, + endDay: mappedDays.last!.date, + totalViewsCount: totalViews, + averageViewsCount: averageViews, + changePercentage: change, + days: mappedDays) + } + + } +} + +extension StatsPostViews { + static func mapDailyData(data: [[Any]]) -> [StatsPostViews] { + return data.compactMap { + guard + let dateString = $0[0] as? String, + let date = StatsPostDetails.dateFormatter.date(from: dateString), + let viewsCount = $0[1] as? Int + else { + return nil + } + + return StatsPostViews(period: .day, + date: Calendar.autoupdatingCurrent.dateComponents([.year, .month, .day], from: date), + viewsCount: viewsCount) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift new file mode 100644 index 000000000000..5ed9ee163079 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/StatsSubscribersSummaryData.swift @@ -0,0 +1,85 @@ +import Foundation +import WordPressShared + +public struct StatsSubscribersSummaryData: Equatable { + public let history: [SubscriberData] + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public init(history: [SubscriberData], period: StatsPeriodUnit, periodEndDate: Date) { + self.history = history + self.period = period + self.periodEndDate = periodEndDate + } +} + +extension StatsSubscribersSummaryData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/subscribers" + } + + static var dateFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + }() + + static var weeksDateFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy'W'MM'W'dd" + return df + }() + + public struct SubscriberData: Equatable { + public let date: Date + public let count: Int + + public init(date: Date, count: Int) { + self.date = date + self.count = count + } + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let fields = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]], + let dateIndex = fields.firstIndex(of: "period"), + let countIndex = fields.firstIndex(of: "subscribers") + else { + return nil + } + + let history: [SubscriberData?] = data.map { elements in + guard elements.indices.contains(dateIndex) && elements.indices.contains(countIndex), + let dateString = elements[dateIndex] as? String, + let date = StatsSubscribersSummaryData.parsedDate(from: dateString, for: period) + else { + return nil + } + + let count = elements[countIndex] as? Int ?? 0 + + return SubscriberData(date: date, count: count) + } + + let sorted = history.compactMap { $0 }.sorted { $0.date < $1.date } + + self = .init(history: sorted, period: period, periodEndDate: date) + } + + private static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { + switch period { + case .week: + return self.weeksDateFormatter.date(from: dateString) + case .day, .month, .year: + return self.dateFormatter.date(from: dateString) + } + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["quantity": String(maxCount), "unit": period.stringValue] + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift new file mode 100644 index 000000000000..da268e47a75b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsFileDownloadsTimeIntervalData.swift @@ -0,0 +1,65 @@ +public struct StatsFileDownloadsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalDownloadsCount: Int + public let otherDownloadsCount: Int + public let fileDownloads: [StatsFileDownload] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + fileDownloads: [StatsFileDownload], + totalDownloadsCount: Int, + otherDownloadsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.fileDownloads = fileDownloads + self.totalDownloadsCount = totalDownloadsCount + self.otherDownloadsCount = otherDownloadsCount + } +} + +public struct StatsFileDownload { + public let file: String + public let downloadCount: Int + + public init(file: String, + downloadCount: Int) { + self.file = file + self.downloadCount = downloadCount + } +} + +extension StatsFileDownloadsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/file-downloads" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + // num = number of periods to include in the query. default: 1. + return ["num": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let fileDownloadsDict = unwrappedDays["files"] as? [[String: AnyObject]] + else { + return nil + } + + let fileDownloads: [StatsFileDownload] = fileDownloadsDict.compactMap { + guard let file = $0["filename"] as? String, let downloads = $0["downloads"] as? Int else { + return nil + } + + return StatsFileDownload(file: file, downloadCount: downloads) + } + + self.periodEndDate = date + self.period = period + self.fileDownloads = fileDownloads + self.totalDownloadsCount = unwrappedDays["total_downloads"] as? Int ?? 0 + self.otherDownloadsCount = unwrappedDays["other_downloads"] as? Int ?? 0 + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsPublishedPostsTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsPublishedPostsTimeIntervalData.swift new file mode 100644 index 000000000000..17bc0a365f75 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsPublishedPostsTimeIntervalData.swift @@ -0,0 +1,48 @@ +public struct StatsPublishedPostsTimeIntervalData { + public let periodEndDate: Date + public let period: StatsPeriodUnit + + public let publishedPosts: [StatsTopPost] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + publishedPosts: [StatsTopPost]) { + self.period = period + self.periodEndDate = periodEndDate + self.publishedPosts = publishedPosts + } +} + +extension StatsPublishedPostsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "posts/" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard let posts = jsonDictionary["posts"] as? [[String: AnyObject]] else { + return nil + } + + self.periodEndDate = date + self.period = period + self.publishedPosts = posts.compactMap { StatsTopPost(postsJSONDictionary: $0) } + } +} + +private extension StatsTopPost { + init?(postsJSONDictionary: [String: AnyObject]) { + guard + let id = postsJSONDictionary["ID"] as? Int, + let title = postsJSONDictionary["title"] as? String, + let urlString = postsJSONDictionary["URL"] as? String + else { + return nil + } + + self.postID = id + self.title = title + self.postURL = URL(string: urlString) + self.viewsCount = 0 + self.kind = .unknown + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSearchTermTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSearchTermTimeIntervalData.swift new file mode 100644 index 000000000000..b1580661c5ed --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSearchTermTimeIntervalData.swift @@ -0,0 +1,68 @@ +public struct StatsSearchTermTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalSearchTermsCount: Int + public let hiddenSearchTermsCount: Int + public let otherSearchTermsCount: Int + public let searchTerms: [StatsSearchTerm] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + searchTerms: [StatsSearchTerm], + totalSearchTermsCount: Int, + hiddenSearchTermsCount: Int, + otherSearchTermsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.searchTerms = searchTerms + self.totalSearchTermsCount = totalSearchTermsCount + self.hiddenSearchTermsCount = hiddenSearchTermsCount + self.otherSearchTermsCount = otherSearchTermsCount + } +} + +public struct StatsSearchTerm { + public let term: String + public let viewsCount: Int + + public init(term: String, + viewsCount: Int) { + self.term = term + self.viewsCount = viewsCount + } +} + +extension StatsSearchTermTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/search-terms" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let totalSearchTerms = unwrappedDays["total_search_terms"] as? Int, + let hiddenSearchTerms = unwrappedDays["encrypted_search_terms"] as? Int, + let otherSearchTerms = unwrappedDays["other_search_terms"] as? Int, + let searchTermsDict = unwrappedDays["search_terms"] as? [[String: AnyObject]] + else { + return nil + } + + let searchTerms: [StatsSearchTerm] = searchTermsDict.compactMap { + guard let term = $0["term"] as? String, let views = $0["views"] as? Int else { + return nil + } + + return StatsSearchTerm(term: term, viewsCount: views) + } + + self.periodEndDate = date + self.period = period + self.totalSearchTermsCount = totalSearchTerms + self.hiddenSearchTermsCount = hiddenSearchTerms + self.otherSearchTermsCount = otherSearchTerms + self.searchTerms = searchTerms + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift new file mode 100644 index 000000000000..d24447fc3642 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsSummaryTimeIntervalData.swift @@ -0,0 +1,271 @@ +public enum StatsPeriodUnit: Int { + case day + case week + case month + case year +} + +public enum StatsSummaryType: Int { + case views + case visitors + case likes + case comments +} + +public struct StatsSummaryTimeIntervalData { + public let period: StatsPeriodUnit + public let unit: StatsPeriodUnit? + public let periodEndDate: Date + + public let summaryData: [StatsSummaryData] + + public init(period: StatsPeriodUnit, + unit: StatsPeriodUnit?, + periodEndDate: Date, + summaryData: [StatsSummaryData]) { + self.period = period + self.unit = unit + self.periodEndDate = periodEndDate + self.summaryData = summaryData + } +} + +public struct StatsSummaryData { + public let period: StatsPeriodUnit + public let periodStartDate: Date + + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init(period: StatsPeriodUnit, + periodStartDate: Date, + viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int) { + self.period = period + self.periodStartDate = periodStartDate + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsSummaryTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": "views,visitors,comments,likes"] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard + let fieldsArray = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + // The shape of data for this response is somewhat unconventional. + // (you might want to take a peek at included tests fixtures files `stats-visits-*.json`) + // There's a `fields` arrray with strings that correspond to requested properties + // (e.g. something like ["period", "views", "visitors"]. + // The actual data we're after is then contained in the `data`... array of arrays? + // The "inner" arrays contain multiple entries, whose indexes correspond to + // the positions of the appropriate keys in the `fields` array, so in our example the array looks something like this: + // [["2019-01-01", 9001, 1234], ["2019-02-01", 1234, 1234]], where the first object in the "inner" array + // is the `period`, second is `views`, etc. + + guard + let periodIndex = fieldsArray.firstIndex(of: "period"), + let viewsIndex = fieldsArray.firstIndex(of: "views"), + let visitorsIndex = fieldsArray.firstIndex(of: "visitors"), + let commentsIndex = fieldsArray.firstIndex(of: "comments"), + let likesIndex = fieldsArray.firstIndex(of: "likes") + else { + return nil + } + + self.period = period + self.unit = unit + self.periodEndDate = date + self.summaryData = data.compactMap { StatsSummaryData(dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: viewsIndex, + visitorsIndex: visitorsIndex, + likesIndex: likesIndex, + commentsIndex: commentsIndex) } + } +} + +private extension StatsSummaryData { + init?(dataArray: [Any], + period: StatsPeriodUnit, + periodIndex: Int, + viewsIndex: Int?, + visitorsIndex: Int?, + likesIndex: Int?, + commentsIndex: Int?) { + + guard + let periodString = dataArray[periodIndex] as? String, + let periodStart = type(of: self).parsedDate(from: periodString, for: period) else { + return nil + } + + let viewsCount: Int + let visitorsCount: Int + let likesCount: Int + let commentsCount: Int + + if let viewsIndex = viewsIndex { + guard let count = dataArray[viewsIndex] as? Int else { + return nil + } + viewsCount = count + } else { + viewsCount = 0 + } + + if let visitorsIndex = visitorsIndex { + guard let count = dataArray[visitorsIndex] as? Int else { + return nil + } + visitorsCount = count + } else { + visitorsCount = 0 + } + + if let likesIndex = likesIndex { + guard let count = dataArray[likesIndex] as? Int else { + return nil + } + likesCount = count + } else { + likesCount = 0 + } + + if let commentsIndex = commentsIndex { + guard let count = dataArray[commentsIndex] as? Int else { + return nil + } + commentsCount = count + } else { + commentsCount = 0 + } + + self.period = period + self.periodStartDate = periodStart + + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } + + static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? { + switch period { + case .week: + return self.weeksDateFormatter.date(from: dateString) + case .day, .month, .year: + return self.regularDateFormatter.date(from: dateString) + } + } + + static var regularDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy-MM-dd" + return df + } + + // We have our own handrolled date format for data broken up on week basis. + // Example dates in this format are `2019W02W18` or `2019W02W11`. + // The structure is `aaaaWbbWcc`, where: + // - `aaaa` is four-digit year number, + // - `bb` is two-digit month number + // - `cc` is two-digit day number + // Note that in contrast to almost every other date used in Stats, those dates + // represent the _beginning_ of the period they're applying to, e.g. + // data set for `2019W02W18` is containing data for the period of Feb 18 - Feb 24 2019. + private static var weeksDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POS") + df.dateFormat = "yyyy'W'MM'W'dd" + return df + } +} + +/// So this is very awkward and neccessiated by our API. Turns out, calculating likes +/// for long periods of times (months/years) on large sites takes _ages_ (up to a minute sometimes). +/// Thankfully, calculating views/visitors/comments takes a much shorter time. (~2s, which is still suuuuuper long, but acceptable.) +/// We don't want to wait a whole minute to display the rest of the data, so we fetch the likes separately. +public struct StatsLikesSummaryTimeIntervalData { + + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let summaryData: [StatsSummaryData] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + summaryData: [StatsSummaryData]) { + self.period = period + self.periodEndDate = periodEndDate + self.summaryData = summaryData + } +} + +extension StatsLikesSummaryTimeIntervalData: StatsTimeIntervalData { + + public static var pathComponent: String { + return "stats/visits" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["unit": period.stringValue, + "quantity": String(maxCount), + "stat_fields": "likes"] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard + let fieldsArray = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] + else { + return nil + } + + guard + let periodIndex = fieldsArray.firstIndex(of: "period"), + let likesIndex = fieldsArray.firstIndex(of: "likes") else { + return nil + } + + self.period = period + self.periodEndDate = date + self.summaryData = data.compactMap { StatsSummaryData(dataArray: $0, + period: unit ?? period, + periodIndex: periodIndex, + viewsIndex: nil, + visitorsIndex: nil, + likesIndex: likesIndex, + commentsIndex: nil) } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift new file mode 100644 index 000000000000..e5e592f81a82 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopAuthorsTimeIntervalData.swift @@ -0,0 +1,139 @@ +public struct StatsTopAuthorsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let topAuthors: [StatsTopAuthor] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + topAuthors: [StatsTopAuthor]) { + self.period = period + self.periodEndDate = periodEndDate + self.topAuthors = topAuthors + } +} + +public struct StatsTopAuthor { + public let name: String + public let iconURL: URL? + public let viewsCount: Int + public let posts: [StatsTopPost] + + public init(name: String, + iconURL: URL?, + viewsCount: Int, + posts: [StatsTopPost]) { + self.name = name + self.iconURL = iconURL + self.viewsCount = viewsCount + self.posts = posts + } +} + +public struct StatsTopPost { + + public enum Kind { + case unknown + case post + case page + case homepage + } + + public let title: String + public let postID: Int + public let postURL: URL? + public let viewsCount: Int + public let kind: Kind + + public init(title: String, + postID: Int, + postURL: URL?, + viewsCount: Int, + kind: Kind) { + self.title = title + self.postID = postID + self.postURL = postURL + self.viewsCount = viewsCount + self.kind = kind + } +} + +extension StatsTopAuthorsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/top-authors/" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let authors = unwrappedDays["authors"] as? [[String: AnyObject]] + else { + return nil + } + + self.period = period + self.periodEndDate = date + self.topAuthors = authors.compactMap { StatsTopAuthor(jsonDictionary: $0) } + } +} + +extension StatsTopAuthor { + init?(jsonDictionary: [String: AnyObject]) { + guard + let name = jsonDictionary["name"] as? String, + let views = jsonDictionary["views"] as? Int, + let avatar = jsonDictionary["avatar"] as? String, + let posts = jsonDictionary["posts"] as? [[String: AnyObject]] + else { + return nil + } + + let url: URL? + if var components = URLComponents(string: avatar) { + components.query = "d=mm&s=60" + url = components.url + } else { + url = nil + } + + let mappedPosts = posts.compactMap { StatsTopPost(jsonDictionary: $0) } + + self.name = name + self.viewsCount = views + self.iconURL = url + self.posts = mappedPosts + + } +} + +extension StatsTopPost { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["title"] as? String, + let postID = jsonDictionary["id"] as? Int, + let viewsCount = jsonDictionary["views"] as? Int, + let postURL = jsonDictionary["url"] as? String + else { + return nil + } + + self.title = title + self.postID = postID + self.viewsCount = viewsCount + self.postURL = URL(string: postURL) + self.kind = type(of: self).kind(from: jsonDictionary["type"] as? String) + } + + static func kind(from kindString: String?) -> Kind { + switch kindString { + case "post": + return .post + case "homepage": + return .homepage + case "page": + return .page + default: + return .unknown + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopClicksTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopClicksTimeIntervalData.swift new file mode 100644 index 000000000000..e368bca26834 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopClicksTimeIntervalData.swift @@ -0,0 +1,94 @@ +public struct StatsTopClicksTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalClicksCount: Int + public let otherClicksCount: Int + + public let clicks: [StatsClick] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + clicks: [StatsClick], + totalClicksCount: Int, + otherClicksCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.clicks = clicks + self.totalClicksCount = totalClicksCount + self.otherClicksCount = otherClicksCount + } +} + +public struct StatsClick { + public let title: String + public let clicksCount: Int + public let clickedURL: URL? + public let iconURL: URL? + + public let children: [StatsClick] + + public init(title: String, + clicksCount: Int, + clickedURL: URL?, + iconURL: URL?, + children: [StatsClick]) { + self.title = title + self.clicksCount = clicksCount + self.clickedURL = clickedURL + self.iconURL = iconURL + self.children = children + } +} + +extension StatsTopClicksTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/clicks" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let clicks = unwrappedDays["clicks"] as? [[String: AnyObject]] + else { + return nil + } + + let totalClicks = unwrappedDays["total_clicks"] as? Int ?? 0 + let otherClicks = unwrappedDays["other_clicks"] as? Int ?? 0 + + self.period = period + self.periodEndDate = date + self.totalClicksCount = totalClicks + self.otherClicksCount = otherClicks + self.clicks = clicks.compactMap { StatsClick(jsonDictionary: $0) } + } +} + +extension StatsClick { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["name"] as? String, + let clicksCount = jsonDictionary["views"] as? Int + else { + return nil + } + + let children: [StatsClick] + + if let childrenJSON = jsonDictionary["children"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsClick(jsonDictionary: $0) } + } else { + children = [] + } + + let icon = jsonDictionary["icon"] as? String + let urlString = jsonDictionary["url"] as? String + + self.title = title + self.clicksCount = clicksCount + self.clickedURL = urlString.flatMap { URL(string: $0) } + self.iconURL = icon.flatMap { URL(string: $0) } + self.children = children + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopCountryTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopCountryTimeIntervalData.swift new file mode 100644 index 000000000000..80c30d71cc6f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopCountryTimeIntervalData.swift @@ -0,0 +1,87 @@ +public struct StatsTopCountryTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalViewsCount: Int + public let otherViewsCount: Int + + public let countries: [StatsCountry] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + countries: [StatsCountry], + totalViewsCount: Int, + otherViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.countries = countries + self.totalViewsCount = totalViewsCount + self.otherViewsCount = otherViewsCount + } +} + +public struct StatsCountry { + public let name: String + public let code: String + public let viewsCount: Int + + public init(name: String, + code: String, + viewsCount: Int) { + self.name = name + self.code = code + self.viewsCount = viewsCount + } +} + +extension StatsTopCountryTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/country-views" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let countriesViews = unwrappedDays["views"] as? [[String: AnyObject]] + else { + return nil + } + + let countryInfo = jsonDictionary["country-info"] as? [String: AnyObject] ?? [:] + let totalViews = unwrappedDays["total_views"] as? Int ?? 0 + let otherViews = unwrappedDays["other_views"] as? Int ?? 0 + + self.periodEndDate = date + self.period = period + + self.totalViewsCount = totalViews + self.otherViewsCount = otherViews + self.countries = countriesViews.compactMap { StatsCountry(jsonDictionary: $0, countryInfo: countryInfo) } + } + +} + +extension StatsCountry { + init?(jsonDictionary: [String: AnyObject], countryInfo: [String: AnyObject]) { + guard + let viewsCount = jsonDictionary["views"] as? Int, + let countryCode = jsonDictionary["country_code"] as? String + else { + return nil + } + + let name: String + + if + let countryDict = countryInfo[countryCode] as? [String: AnyObject], + let countryName = countryDict["country_full"] as? String { + name = countryName + } else { + name = countryCode + } + + self.viewsCount = viewsCount + self.code = countryCode + self.name = name + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift new file mode 100644 index 000000000000..cefd7da32db1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopPostsTimeIntervalData.swift @@ -0,0 +1,68 @@ +public struct StatsTopPostsTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalViewsCount: Int + public let otherViewsCount: Int + public let topPosts: [StatsTopPost] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + topPosts: [StatsTopPost], + totalViewsCount: Int, + otherViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.topPosts = topPosts + self.totalViewsCount = totalViewsCount + self.otherViewsCount = otherViewsCount + } +} + +extension StatsTopPostsTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/top-posts" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let posts = unwrappedDays["postviews"] as? [[String: AnyObject]] + else { + return nil + } + + let totalViews = unwrappedDays["total_views"] as? Int ?? 0 + let otherViews = unwrappedDays["other_views"] as? Int ?? 0 + + self.periodEndDate = date + self.period = period + self.totalViewsCount = totalViews + self.otherViewsCount = otherViews + self.topPosts = posts.compactMap { StatsTopPost(topPostsJSONDictionary: $0) } + } +} + +private extension StatsTopPost { + + // the objects returned from this endpoint are _almost_ the same as the ones from `top-posts`, + // but with keys just subtly different enough that we need a custom init here. + init?(topPostsJSONDictionary jsonDictionary: [String: AnyObject]) { + guard + let url = jsonDictionary["href"] as? String, + let postID = jsonDictionary["id"] as? Int, + let title = jsonDictionary["title"] as? String, + let viewsCount = jsonDictionary["views"] as? Int, + let typeString = jsonDictionary["type"] as? String + else { + return nil + } + + self.title = title + self.postID = postID + self.postURL = URL(string: url) + self.viewsCount = viewsCount + self.kind = type(of: self).kind(from: typeString) + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopReferrersTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopReferrersTimeIntervalData.swift new file mode 100644 index 000000000000..b859a2d9af51 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopReferrersTimeIntervalData.swift @@ -0,0 +1,110 @@ +public struct StatsTopReferrersTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalReferrerViewsCount: Int + public let otherReferrerViewsCount: Int + + public var referrers: [StatsReferrer] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + referrers: [StatsReferrer], + totalReferrerViewsCount: Int, + otherReferrerViewsCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.referrers = referrers + self.totalReferrerViewsCount = totalReferrerViewsCount + self.otherReferrerViewsCount = otherReferrerViewsCount + } +} + +public struct StatsReferrer { + public let title: String + public let viewsCount: Int + public let url: URL? + public let iconURL: URL? + + public var children: [StatsReferrer] + public var isSpam = false + + public init(title: String, + viewsCount: Int, + url: URL?, + iconURL: URL?, + children: [StatsReferrer]) { + self.title = title + self.viewsCount = viewsCount + self.url = url + self.iconURL = iconURL + self.children = children + } +} + +extension StatsTopReferrersTimeIntervalData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/referrers" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let referrers = unwrappedDays["groups"] as? [[String: AnyObject]] + else { + return nil + } + + let totalClicks = unwrappedDays["total_views"] as? Int ?? 0 + let otherClicks = unwrappedDays["other_views"] as? Int ?? 0 + + self.period = period + self.periodEndDate = date + self.totalReferrerViewsCount = totalClicks + self.otherReferrerViewsCount = otherClicks + self.referrers = referrers.compactMap { StatsReferrer(jsonDictionary: $0) } + } +} + +extension StatsReferrer { + init?(jsonDictionary: [String: AnyObject]) { + guard + let title = jsonDictionary["name"] as? String + else { + return nil + } + + // The shape of API reply here is _almost_ a perfectly fractal tree structure. + // However, sometimes the keys for children/parents representing the same values change, hence this + // rether ugly hack. + let viewsCount: Int + + if let views = jsonDictionary["total"] as? Int { + viewsCount = views + } else if let views = jsonDictionary["views"] as? Int { + viewsCount = views + } else { + // If neither key is present, this is a malformed response. + return nil + } + + let children: [StatsReferrer] + + if let childrenJSON = jsonDictionary["results"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsReferrer(jsonDictionary: $0) } + } else if let childrenJSON = jsonDictionary["children"] as? [[String: AnyObject]] { + children = childrenJSON.compactMap { StatsReferrer(jsonDictionary: $0) } + } else { + children = [] + } + + let icon = jsonDictionary["icon"] as? String + let urlString = jsonDictionary["url"] as? String + + self.title = title + self.viewsCount = viewsCount + self.url = urlString.flatMap { URL(string: $0) } + self.iconURL = icon.flatMap { URL(string: $0) } + self.children = children + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopVideosTimeIntervalData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopVideosTimeIntervalData.swift new file mode 100644 index 000000000000..e61999d1b486 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTopVideosTimeIntervalData.swift @@ -0,0 +1,79 @@ +public struct StatsTopVideosTimeIntervalData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + + public let totalPlaysCount: Int + public let otherPlayCount: Int + public let videos: [StatsVideo] + + public init(period: StatsPeriodUnit, + periodEndDate: Date, + videos: [StatsVideo], + totalPlaysCount: Int, + otherPlayCount: Int) { + self.period = period + self.periodEndDate = periodEndDate + self.videos = videos + self.totalPlaysCount = totalPlaysCount + self.otherPlayCount = otherPlayCount + } +} + +public struct StatsVideo { + public let postID: Int + public let title: String + public let playsCount: Int + public let videoURL: URL? + + public init(postID: Int, + title: String, + playsCount: Int, + videoURL: URL?) { + self.postID = postID + self.title = title + self.playsCount = playsCount + self.videoURL = videoURL + } +} + +extension StatsTopVideosTimeIntervalData: StatsTimeIntervalData { + + public static var pathComponent: String { + return "stats/video-plays" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + guard + let unwrappedDays = type(of: self).unwrapDaysDictionary(jsonDictionary: jsonDictionary), + let totalPlayCount = unwrappedDays["total_plays"] as? Int, + let otherPlays = unwrappedDays["other_plays"] as? Int, + let videos = unwrappedDays["plays"] as? [[String: AnyObject]] + else { + return nil + } + + self.period = period + self.periodEndDate = date + self.totalPlaysCount = totalPlayCount + self.otherPlayCount = otherPlays + self.videos = videos.compactMap { StatsVideo(jsonDictionary: $0) } + } +} + +extension StatsVideo { + init?(jsonDictionary: [String: AnyObject]) { + guard + let postID = jsonDictionary["post_id"] as? Int, + let title = jsonDictionary["title"] as? String, + let playsCount = jsonDictionary["plays"] as? Int, + let url = jsonDictionary["url"] as? String + else { + return nil + } + + self.postID = postID + self.title = title + self.playsCount = playsCount + self.videoURL = URL(string: url) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTotalsSummaryData.swift b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTotalsSummaryData.swift new file mode 100644 index 000000000000..5e8e9958bb5b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Stats/Time Interval/StatsTotalsSummaryData.swift @@ -0,0 +1,39 @@ +public struct StatsTotalsSummaryData { + public let period: StatsPeriodUnit + public let periodEndDate: Date + public let viewsCount: Int + public let visitorsCount: Int + public let likesCount: Int + public let commentsCount: Int + + public init( + period: StatsPeriodUnit, + periodEndDate: Date, + viewsCount: Int, + visitorsCount: Int, + likesCount: Int, + commentsCount: Int + ) { + self.period = period + self.periodEndDate = periodEndDate + self.viewsCount = viewsCount + self.visitorsCount = visitorsCount + self.likesCount = likesCount + self.commentsCount = commentsCount + } +} + +extension StatsTotalsSummaryData: StatsTimeIntervalData { + public static var pathComponent: String { + return "stats/summary" + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.period = period + self.periodEndDate = date + self.visitorsCount = jsonDictionary["visitors"] as? Int ?? 0 + self.viewsCount = jsonDictionary["views"] as? Int ?? 0 + self.likesCount = jsonDictionary["likes"] as? Int ?? 0 + self.commentsCount = jsonDictionary["comments"] as? Int ?? 0 + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/WPCountry.swift b/WordPressKit/Sources/WordPressKit/Models/WPCountry.swift new file mode 100644 index 000000000000..5a0141941a01 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/WPCountry.swift @@ -0,0 +1,6 @@ +import Foundation + +@objc public class WPCountry: NSObject, Codable { + public var code: String? + public var name: String? +} diff --git a/WordPressKit/Sources/WordPressKit/Models/WPState.swift b/WordPressKit/Sources/WordPressKit/Models/WPState.swift new file mode 100644 index 000000000000..630ad40d9d51 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/WPState.swift @@ -0,0 +1,6 @@ +import Foundation + +@objc public class WPState: NSObject, Codable { + public var code: String? + public var name: String? +} diff --git a/WordPressKit/Sources/WordPressKit/Models/WPTimeZone.swift b/WordPressKit/Sources/WordPressKit/Models/WPTimeZone.swift new file mode 100644 index 000000000000..8974acef8956 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/WPTimeZone.swift @@ -0,0 +1,98 @@ +import Foundation + +/// A model to represent known WordPress.com timezones +/// +public protocol WPTimeZone { + var label: String { get } + var value: String { get } + + var gmtOffset: Float? { get } + var timezoneString: String? { get } +} + +extension WPTimeZone { + public var timezoneString: String? { + return value + } +} + +public struct TimeZoneGroup { + public let name: String + public let timezones: [WPTimeZone] + + public init(name: String, timezones: [WPTimeZone]) { + self.name = name + self.timezones = timezones + } +} + +public struct NamedTimeZone: WPTimeZone { + public let label: String + public let value: String + + public var gmtOffset: Float? { + return nil + } +} + +public struct OffsetTimeZone: WPTimeZone { + let offset: Float + + public init(offset: Float) { + self.offset = offset + } + + public var label: String { + if offset == 0 { + return "UTC" + } else if offset > 0 { + return "UTC+\(hourOffset)\(minuteOffsetString)" + } else { + return "UTC\(hourOffset)\(minuteOffsetString)" + } + } + + public var value: String { + let offsetString = String(format: "%g", offset) + if offset == 0 { + return "UTC" + } else if offset > 0 { + return "UTC+\(offsetString)" + } else { + return "UTC\(offsetString)" + } + } + + public var gmtOffset: Float? { + return offset + } + + static func fromValue(_ value: String) -> OffsetTimeZone? { + guard let offsetString = try? value.removingPrefix(pattern: "UTC") else { + return nil + } + let offset: Float? + if offsetString.isEmpty { + offset = 0 + } else { + offset = Float(offsetString) + } + return offset.map(OffsetTimeZone.init) + } + + private var hourOffset: Int { + return Int(offset.rounded(.towardZero)) + } + + private var minuteOffset: Int { + return Int(abs(offset.truncatingRemainder(dividingBy: 1) * 60)) + } + + private var minuteOffsetString: String { + if minuteOffset != 0 { + return ":\(minuteOffset)" + } else { + return "" + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Private/WPKit-Swift.h b/WordPressKit/Sources/WordPressKit/Private/WPKit-Swift.h new file mode 100644 index 000000000000..42be4ee185d6 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Private/WPKit-Swift.h @@ -0,0 +1,8 @@ +// Import this header instead of +// This allows the pod to be built as a static or dynamic framework +// See https://github.com/CocoaPods/CocoaPods/issues/7594 +#if __has_include("WordPressKit-Swift.h") + #import "WordPressKit-Swift.h" +#else + #import +#endif diff --git a/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemote.h new file mode 100644 index 000000000000..b9c02d06ec0f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemote.h @@ -0,0 +1,113 @@ +#import +#import + +@class WPAccount; + +static NSString * const AccountServiceRemoteErrorDomain = @"AccountServiceErrorDomain"; + +typedef NS_ERROR_ENUM(AccountServiceRemoteErrorDomain, AccountServiceRemoteError) { + AccountServiceRemoteCantReadServerResponse, + AccountServiceRemoteEmailAddressInvalid, + AccountServiceRemoteEmailAddressCheckError, +}; + +@protocol AccountServiceRemote + +/** + * @brief Gets blogs for an account. + * + * @param filterJetpackSites Whether we're fetching only Jetpack blogs. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getBlogs:(BOOL)filterJetpackSites + success:(void (^)(NSArray *blogs))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Gets all blogs for an account. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getBlogsWithSuccess:(void (^)(NSArray *blogs))success + failure:(void (^)(NSError *error))failure; + + +/** + * @brief Gets only visible blogs for an account. + * + * @discussion This method is designed for use in extensions in order to provide a simple + * way to retrieve a quick list of availible sites. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getVisibleBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Gets an account's details. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAccountDetailsWithSuccess:(void (^)(RemoteUser *remoteUser))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates blogs' visibility + * + * @param blogs A dictionary with blog IDs as keys and a boolean indicating visibility as values. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogsVisibility:(NSDictionary *)blogs + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to check if a wpcom account requires a passwordless login option. + * @note Note that if there is no acccount matching the supplied identifier + * the REST endpoing returns a 404 error code. + * + * @param identifier May be an email address, username, or user ID. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isPasswordlessAccount:(NSString *)identifier + success:(void (^)(BOOL passwordless))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to see if an email address is paired with a wpcom acccount + * or if it is available. Used in the auth link signup flow. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isEmailAvailable:(NSString *)email + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Query to see if a username is available. Used in the auth link signup flow. + * @note This is an unversioned endpoint. Success will mean, generally, that the username already exists. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)isUsernameAvailable:(NSString *)username + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure; + + /** + * @brief Request to (re-)send the verification email for the current user. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestVerificationEmailWithSucccess:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST+SocialService.swift b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST+SocialService.swift new file mode 100644 index 000000000000..02432a1f6846 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST+SocialService.swift @@ -0,0 +1,82 @@ +import Foundation + +public enum SocialServiceName: String { + case google + case apple +} + +extension AccountServiceRemoteREST { + + /// Connect to the specified social service via its OpenID Connect (JWT) token. + /// + /// - Parameters: + /// - service The name of the social service. + /// - token The OpenID Connect (JWT) ID token identifying the user on the social service. + /// - connectParameters Dictionary containing additional endpoint parameters. Currently only used for the Apple service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. + /// - success The block that will be executed on success. + /// - failure The block that will be executed on failure. + public func connectToSocialService(_ service: SocialServiceName, + serviceIDToken token: String, + connectParameters: [String: AnyObject]? = nil, + oAuthClientID: String, + oAuthClientSecret: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let path = self.path(forEndpoint: "me/social-login/connect", withVersion: ._1_1) + + var params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, + "service": service.rawValue, + "id_token": token + ] as [String: AnyObject] + + if let connectParameters = connectParameters { + params.merge(connectParameters, uniquingKeysWith: { (current, _) in current }) + } + + wordPressComRESTAPI.post(path, parameters: params, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } + + /// Get Apple connect parameters from provided account information. + /// + /// - Parameters: + /// - email Email from Apple account. + /// - fullName User's full name from Apple account. + /// - Returns: Dictionary with endpoint parameters, to be used when connecting to social service. + static public func appleSignInParameters(email: String, fullName: String) -> [String: AnyObject] { + return [ + "user_email": email as AnyObject, + "user_name": fullName as AnyObject + ] + } + + /// Disconnect fromm the specified social service. + /// + /// - Parameters: + /// - service The name of the social service. + /// - oAuthClientID The WPCOM REST API client ID. + /// - oAuthClientSecret The WPCOM REST API client secret. + /// - success The block that will be executed on success. + /// - failure The block that will be executed on failure. + public func disconnectFromSocialService(_ service: SocialServiceName, oAuthClientID: String, oAuthClientSecret: String, success: @escaping(() -> Void), failure: @escaping((Error) -> Void)) { + let path = self.path(forEndpoint: "me/social-login/disconnect", withVersion: ._1_1) + let params = [ + "client_id": oAuthClientID, + "client_secret": oAuthClientSecret, + "service": service.rawValue + ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: params, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.h new file mode 100644 index 000000000000..d110bc95daf1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.h @@ -0,0 +1,47 @@ +#import +#import +#import + +typedef NSString* const MagicLinkParameter NS_TYPED_ENUM; +extern MagicLinkParameter const MagicLinkParameterFlow; +extern MagicLinkParameter const MagicLinkParameterSource; + +typedef NSString* const MagicLinkSource NS_TYPED_ENUM; +extern MagicLinkSource const MagicLinkSourceDefault; +extern MagicLinkSource const MagicLinkSourceJetpackConnect; + +//typedef NSString* const MagicLinkFlow NS_TYPED_ENUM; +typedef NSString* const MagicLinkFlow NS_STRING_ENUM; +extern MagicLinkFlow const MagicLinkFlowLogin; +extern MagicLinkFlow const MagicLinkFlowSignup; + +@interface AccountServiceRemoteREST : ServiceRemoteWordPressComREST + +/** +* @brief Request an authentication link be sent to the email address provided. +* + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Request a signup link be sent to the email address provided. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)requestWPComSignupLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.m new file mode 100644 index 000000000000..2d938c78eaed --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AccountServiceRemoteREST.m @@ -0,0 +1,395 @@ +#import "AccountServiceRemoteREST.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +static NSString * const UserDictionaryIDKey = @"ID"; +static NSString * const UserDictionaryUsernameKey = @"username"; +static NSString * const UserDictionaryEmailKey = @"email"; +static NSString * const UserDictionaryDisplaynameKey = @"display_name"; +static NSString * const UserDictionaryPrimaryBlogKey = @"primary_blog"; +static NSString * const UserDictionaryAvatarURLKey = @"avatar_URL"; +static NSString * const UserDictionaryDateKey = @"date"; +static NSString * const UserDictionaryEmailVerifiedKey = @"email_verified"; + +MagicLinkParameter const MagicLinkParameterFlow = @"flow"; +MagicLinkParameter const MagicLinkParameterSource = @"source"; + +MagicLinkSource const MagicLinkSourceDefault = @"default"; +MagicLinkSource const MagicLinkSourceJetpackConnect = @"jetpack"; + +MagicLinkFlow const MagicLinkFlowLogin = @"login"; +MagicLinkFlow const MagicLinkFlowSignup = @"signup"; + +@interface AccountServiceRemoteREST () + +@end + +@implementation AccountServiceRemoteREST + +- (void)getBlogs:(BOOL)filterJetpackSites + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + if (filterJetpackSites) { + [self getBlogsWithParameters:@{@"filters": @"jetpack"} success:success failure:failure]; + } else { + [self getBlogsWithSuccess:success failure:failure]; + } +} + +- (void)getBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getBlogsWithParameters:nil success:success failure:failure]; +} + +- (void)getVisibleBlogsWithSuccess:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getBlogsWithParameters:@{@"site_visibility": @"visible"} success:success failure:failure]; +} + +- (void)getAccountDetailsWithSuccess:(void (^)(RemoteUser *remoteUser))success + failure:(void (^)(NSError *error))failure +{ + NSString *requestUrl = [self pathForEndpoint:@"me" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + RemoteUser *remoteUser = [self remoteUserFromDictionary:responseObject]; + success(remoteUser); + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogsVisibility:(NSDictionary *)blogs + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([blogs isKindOfClass:[NSDictionary class]]); + + /* + The `POST me/sites` endpoint expects it's input in a format like: + @{ + @"sites": @[ + @"1234": { + @"visible": @YES + }, + @"2345": { + @"visible": @NO + }, + ] + } + */ + NSMutableDictionary *sites = [NSMutableDictionary dictionaryWithCapacity:blogs.count]; + [blogs enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + NSParameterAssert([key isKindOfClass:[NSNumber class]]); + NSParameterAssert([obj isKindOfClass:[NSNumber class]]); + /* + Blog IDs are pased as strings because JSON dictionaries can't take + non-string keys. If you try, you get a NSInvalidArgumentException + */ + NSString *blogID = [key stringValue]; + sites[blogID] = @{ @"visible": obj }; + }]; + + NSDictionary *parameters = @{ + @"sites": sites + }; + NSString *path = [self pathForEndpoint:@"me/sites" + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI post:path + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isPasswordlessAccount:(NSString *)identifier success:(void (^)(BOOL passwordless))success failure:(void (^)(NSError *error))failure +{ + NSString *encodedIdentifier = [identifier stringByAddingPercentEncodingWithAllowedCharacters:NSCharacterSet.URLPathRFC3986AllowedCharacterSet]; + + NSString *path = [self pathForEndpoint:[NSString stringWithFormat:@"users/%@/auth-options", encodedIdentifier] + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI get:path + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL passwordless = [[dict numberForKey:@"passwordless"] boolValue]; + success(passwordless); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isEmailAvailable:(NSString *)email success:(void (^)(BOOL available))success failure:(void (^)(NSError *error))failure +{ + static NSString * const errorEmailAddressInvalid = @"invalid"; + static NSString * const errorEmailAddressTaken = @"taken"; + + [self.wordPressComRESTAPI get:@"is-available/email" + parameters:@{ @"q": email, @"format": @"json"} + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if ([responseObject isKindOfClass:[NSDictionary class]]) { + NSString *error = [responseObject objectForKey:@"error"]; + NSString *message = [responseObject objectForKey:@"message"]; + + if (error != NULL) { + if ([error isEqualToString:errorEmailAddressTaken]) { + // While this is informed as an error by the endpoint, for the purpose of this method + // it's a success case. We just need to inform the caller that the email is not + // available. + success(false); + } else if ([error isEqualToString:errorEmailAddressInvalid]) { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteEmailAddressInvalid + userInfo:@{ + @"response": responseObject, + NSLocalizedDescriptionKey: message, + }]; + if (failure) { + failure(error); + } + } else { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteEmailAddressCheckError + userInfo:@{ + @"response": responseObject, + NSLocalizedDescriptionKey: message, + }]; + if (failure) { + failure(error); + } + } + + return; + } + + if (success) { + BOOL available = [[responseObject numberForKey:@"available"] boolValue]; + success(available); + } + } else { + NSError* error = [[NSError alloc] initWithDomain:AccountServiceRemoteErrorDomain + code:AccountServiceRemoteCantReadServerResponse + userInfo:@{@"response": responseObject}]; + + if (failure) { + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)isUsernameAvailable:(NSString *)username + success:(void (^)(BOOL available))success + failure:(void (^)(NSError *error))failure +{ + [self.wordPressComRESTAPI get:@"is-available/username" + parameters:@{ @"q": username, @"format": @"json"} + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + // currently the endpoint will not respond with available=false + // but it could one day, and this should still work in that case + BOOL available = NO; + if ([responseObject isKindOfClass:[NSDictionary class]]) { + NSDictionary *dict = (NSDictionary *)responseObject; + available = [[dict numberForKey:@"available"] boolValue]; + } + success(available); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + // If the username is not available (has already been used) + // the endpoint will reply with a 200 status code but describe + // an error. This causes a JSON error, which we can test for here. + if (httpResponse.statusCode == 200 && [error.description containsString:@"JSON"]) { + if (success) { + success(true); + } + } else if (failure) { + failure(error); + } + }]; +} + +- (void)requestWPComAuthLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + source:(MagicLinkSource)source + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForEndpoint:@"auth/send-login-email" + withVersion:WordPressComRESTAPIVersion_1_3]; + + NSDictionary *extraParams = @{ + MagicLinkParameterFlow: MagicLinkFlowLogin, + MagicLinkParameterSource: source + }; + + [self requestWPComMagicLinkForEmail:email + path:path + clientID:clientID + clientSecret:clientSecret + extraParams:extraParams + wpcomScheme:scheme + success:success + failure:failure]; +} + +- (void)requestWPComSignupLinkForEmail:(NSString *)email + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + + NSString *path = [self pathForEndpoint:@"auth/send-signup-email" + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *extraParams = @{ + @"signup_flow_name": @"mobile-ios", + MagicLinkParameterFlow: MagicLinkFlowSignup + }; + + [self requestWPComMagicLinkForEmail:email + path:path + clientID:clientID + clientSecret:clientSecret + extraParams:extraParams + wpcomScheme:scheme + success:success + failure:failure]; +} + +- (void)requestWPComMagicLinkForEmail:(NSString *)email + path:(NSString *)path + clientID:(NSString *)clientID + clientSecret:(NSString *)clientSecret + extraParams:(nullable NSDictionary *)extraParams + wpcomScheme:(NSString *)scheme + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSAssert([email length] > 0, @"Needs an email address."); + + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithDictionary:@{ + @"email": email, + @"client_id": clientID, + @"client_secret": clientSecret + }]; + + if (![@"wordpress" isEqualToString:scheme]) { + [params setObject:scheme forKey:@"scheme"]; + } + + if (extraParams != nil) { + [params addEntriesFromDictionary:extraParams]; + } + + [self.wordPressComRESTAPI post:path + parameters:[NSDictionary dictionaryWithDictionary:params] + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)requestVerificationEmailWithSucccess:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForEndpoint:@"me/send-verification-email" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:path parameters:nil success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + if (success) { + success(); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse * _Nullable response) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private Methods + +- (void)getBlogsWithParameters:(NSDictionary *)parameters + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSString *requestUrl = [self pathForEndpoint:@"me/sites" + withVersion:WordPressComRESTAPIVersion_1_2]; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remoteBlogsFromJSONArray:responseObject[@"sites"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteUser *)remoteUserFromDictionary:(NSDictionary *)dictionary +{ + RemoteUser *remoteUser = [RemoteUser new]; + remoteUser.userID = [dictionary numberForKey:UserDictionaryIDKey]; + remoteUser.username = [dictionary stringForKey:UserDictionaryUsernameKey]; + remoteUser.email = [dictionary stringForKey:UserDictionaryEmailKey]; + remoteUser.displayName = [dictionary stringForKey:UserDictionaryDisplaynameKey]; + remoteUser.primaryBlogID = [dictionary numberForKey:UserDictionaryPrimaryBlogKey]; + remoteUser.avatarURL = [dictionary stringForKey:UserDictionaryAvatarURLKey]; + remoteUser.dateCreated = [NSDate dateWithISO8601String:[dictionary stringForKey:UserDictionaryDateKey]]; + remoteUser.emailVerified = [[dictionary numberForKey:UserDictionaryEmailVerifiedKey] boolValue]; + + return remoteUser; +} + +- (NSArray *)remoteBlogsFromJSONArray:(NSArray *)jsonBlogs +{ + NSArray *blogs = jsonBlogs; + return [blogs wp_map:^id(NSDictionary *jsonBlog) { + return [[RemoteBlog alloc] initWithJSONDictionary:jsonBlog]; + }]; + return blogs; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/AccountSettingsRemote.swift b/WordPressKit/Sources/WordPressKit/Services/AccountSettingsRemote.swift new file mode 100644 index 000000000000..e029ab5d3351 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AccountSettingsRemote.swift @@ -0,0 +1,228 @@ +import Foundation +import WordPressShared + +public class AccountSettingsRemote: ServiceRemoteWordPressComREST { + @objc public static let remotes = NSMapTable(keyOptions: NSPointerFunctions.Options(), valueOptions: NSPointerFunctions.Options.weakMemory) + + /// Returns an AccountSettingsRemote with the given api, reusing a previous + /// remote if it exists. + @objc public static func remoteWithApi(_ api: WordPressComRestApi) -> AccountSettingsRemote { + // We're hashing on the authToken because we don't want duplicate api + // objects for the same account. + // + // In theory this would be taken care of by the fact that the api comes + // from a WPAccount, and since WPAccount is a managed object Core Data + // guarantees there's only one of it. + // + // However it might be possible that the account gets deallocated and + // when it's fetched again it would create a different api object. + // FIXME: not thread safe + // @koke 2016-01-21 + if let remote = remotes.object(forKey: api) as? AccountSettingsRemote { + return remote + } else { + let remote = AccountSettingsRemote(wordPressComRestApi: api) + remotes.setObject(remote, forKey: api) + return remote + } + } + + public func getSettings(success: @escaping (AccountSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let parameters = ["context": "edit"] + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: parameters as [String: AnyObject]?, + success: { + responseObject, _ in + + do { + let settings = try self.settingsFromResponse(responseObject) + success(settings) + } catch { + failure(error) + } + }, + failure: { error, _ in + failure(error) + }) + } + + public func updateSetting(_ change: AccountSettingsChange, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [fieldNameForChange(change): change.stringValue] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { + _, _ in + + success() + }, + failure: { error, _ in + failure(error) + }) + } + + /// Change the current user's username + /// + /// - Parameters: + /// - username: the new username + /// - success: block for success + /// - failure: block for failure + public func changeUsername(to username: String, success: @escaping () -> Void, failure: @escaping () -> Void) { + let endpoint = "me/username" + let action = "none" + let parameters = ["username": username, "action": action] + + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { _, _ in + success() + }, + failure: { _, _ in + failure() + }) + } + + /// Validate the current user's username + /// + /// - Parameters: + /// - username: The new username + /// - success: block for success + /// - failure: block for failure + public func validateUsername(to username: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/username/validate/\(username)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { _, _ in + // The success block needs to be changed if + // any allowed_actions is required + // by the changeUsername API + success() + }, + failure: { error, _ in + failure(error) + }) + } + + public func suggestUsernames(base: String, finished: @escaping ([String]) -> Void) { + let endpoint = "wpcom/v2/users/username/suggestions" + let parameters = ["name": base] + + wordPressComRESTAPI.get(endpoint, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let suggestions = response["suggestions"] as? [String] else { + finished([]) + return + } + + finished(suggestions) + }) { (_, _) in + finished([]) + } + } + + public func updatePassword(_ password: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/settings" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["password": password] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { + _, _ in + success() + }, + failure: { error, _ in + failure(error) + }) + } + + public func closeAccount(success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/account/close" + let path = path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil) { _, _ in + success() + } failure: { error, _ in + failure(error) + } + } + + private func settingsFromResponse(_ responseObject: Any) throws -> AccountSettings { + guard let response = responseObject as? [String: AnyObject], + let firstName = response["first_name"] as? String, + let lastName = response["last_name"] as? String, + let displayName = response["display_name"] as? String, + let aboutMe = response["description"] as? String, + let username = response["user_login"] as? String, + let usernameCanBeChanged = response["user_login_can_be_changed"] as? Bool, + let email = response["user_email"] as? String, + let emailPendingAddress = response["new_user_email"] as? String?, + let emailPendingChange = response["user_email_change_pending"] as? Bool, + let primarySiteID = response["primary_site_ID"] as? Int, + let webAddress = response["user_URL"] as? String, + let language = response["language"] as? String, + let tracksOptOut = response["tracks_opt_out"] as? Bool, + let blockEmailNotifications = response["subscription_delivery_email_blocked"] as? Bool, + let twoStepEnabled = response["two_step_enabled"] as? Bool + else { + WPKitLogError("Error decoding me/settings response: \(responseObject)") + throw ResponseError.decodingFailure + } + + let aboutMeText = aboutMe.decodingXMLCharacters() + + return AccountSettings(firstName: firstName, + lastName: lastName, + displayName: displayName, + aboutMe: aboutMeText!, + username: username, + usernameCanBeChanged: usernameCanBeChanged, + email: email, + emailPendingAddress: emailPendingAddress, + emailPendingChange: emailPendingChange, + primarySiteID: primarySiteID, + webAddress: webAddress, + language: language, + tracksOptOut: tracksOptOut, + blockEmailNotifications: blockEmailNotifications, + twoStepEnabled: twoStepEnabled) + } + + private func fieldNameForChange(_ change: AccountSettingsChange) -> String { + switch change { + case .firstName: + return "first_name" + case .lastName: + return "last_name" + case .displayName: + return "display_name" + case .aboutMe: + return "description" + case .email: + return "user_email" + case .emailRevertPendingChange: + return "user_email_change_pending" + case .primarySite: + return "primary_site_ID" + case .webAddress: + return "user_URL" + case .language: + return "language" + case .tracksOptOut: + return "tracks_opt_out" + } + } + + enum ResponseError: Error { + case decodingFailure + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote.swift new file mode 100644 index 000000000000..d81ab3273234 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote.swift @@ -0,0 +1,224 @@ +import Foundation +import WordPressShared + +open class ActivityServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + private lazy var formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = ("yyyy-MM-dd HH:mm:ss") + formatter.timeZone = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + return formatter + }() + + /// Retrieves activity events associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - offset: The first N activities to be skipped in the returned array. + /// - count: Number of objects to retrieve. + /// - after: Only activies after the given Date will be returned + /// - before: Only activies before the given Date will be returned + /// - group: Array of strings of activity types, eg. post, attachment, user + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of activities and a boolean indicating if there's more activities to fetch. + /// + open func getActivityForSite(_ siteID: Int, + offset: Int = 0, + count: Int, + after: Date? = nil, + before: Date? = nil, + group: [String] = [], + success: @escaping (_ activities: [Activity], _ hasMore: Bool) -> Void, + failure: @escaping (Error) -> Void) { + + var path = URLComponents(string: "sites/\(siteID)/activity") + + path?.queryItems = group.map { URLQueryItem(name: "group[]", value: $0) } + + let pageNumber = (offset / count) + 1 + path?.queryItems?.append(URLQueryItem(name: "number", value: "\(count)")) + path?.queryItems?.append(URLQueryItem(name: "page", value: "\(pageNumber)")) + + if let after = after, let before = before, + let lastSecondOfBeforeDay = before.endOfDay() { + path?.queryItems?.append(URLQueryItem(name: "after", value: formatter.string(from: after))) + path?.queryItems?.append(URLQueryItem(name: "before", value: formatter.string(from: lastSecondOfBeforeDay))) + } else if let on = after ?? before { + path?.queryItems?.append(URLQueryItem(name: "on", value: formatter.string(from: on))) + } + + guard let endpoint = path?.string else { + return + } + + let finalPath = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(finalPath, + parameters: nil, + success: { response, _ in + do { + let (activities, totalItems) = try self.mapActivitiesResponse(response) + let hasMore = totalItems > pageNumber * (count + 1) + success(activities, hasMore) + } catch { + WPKitLogError("Error parsing activity response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves activity groups associated with a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - after: Only activity groups after the given Date will be returned. + /// - before: Only activity groups before the given Date will be returned. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of available activity groups for a site. + /// + open func getActivityGroupsForSite(_ siteID: Int, + after: Date? = nil, + before: Date? = nil, + success: @escaping (_ groups: [ActivityGroup]) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/activity/count/group" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + var parameters: [String: AnyObject] = [:] + + if let after = after, let before = before, + let lastSecondOfBeforeDay = before.endOfDay() { + parameters["after"] = formatter.string(from: after) as AnyObject + parameters["before"] = formatter.string(from: lastSecondOfBeforeDay) as AnyObject + } else if let on = after ?? before { + parameters["on"] = formatter.string(from: on) as AnyObject + } + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + do { + let groups = try self.mapActivityGroupsResponse(response) + success(groups) + } catch { + WPKitLogError("Error parsing activity groups for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves the site current rewind state. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// + /// - Returns: The current rewind status for the site. + /// + open func getRewindStatus(_ siteID: Int, + success: @escaping (RewindStatus) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/rewind" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let rewindStatus = response as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let status = try RewindStatus(dictionary: rewindStatus) + success(status) + } catch { + WPKitLogError("Error parsing rewind response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(ResponseError.decodingFailure) + } + }, failure: { error, _ in + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + let nsError = error as NSError + + guard nsError.domain == WordPressComRestApiEndpointError.errorDomain, + nsError.code == WordPressComRestApiErrorCode.preconditionFailure.rawValue else { + failure(error) + return + } + + let status = RewindStatus(state: .unavailable) + success(status) + return + }) + } + +} + +private extension ActivityServiceRemote { + + func mapActivitiesResponse(_ response: Any) throws -> ([Activity], Int) { + + guard let json = response as? [String: AnyObject], + let totalItems = json["totalItems"] as? Int else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + guard totalItems > 0 else { + return ([], 0) + } + + guard let current = json["current"] as? [String: AnyObject], + let orderedItems = current["orderedItems"] as? [[String: AnyObject]] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .supportMultipleDateFormats + let data = try JSONSerialization.data(withJSONObject: orderedItems, options: []) + let activities = try decoder.decode([Activity].self, from: data) + + return (activities, totalItems) + + } catch { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + } + + func mapActivityGroupsResponse(_ response: Any) throws -> ([ActivityGroup]) { + guard let json = response as? [String: AnyObject], + let totalItems = json["totalItems"] as? Int, totalItems > 0 else { + return [] + } + + guard let rawGroups = json["groups"] as? [String: AnyObject] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + + let groups: [ActivityGroup] = try rawGroups.map { (key, value) -> ActivityGroup in + guard let group = value as? [String: AnyObject] else { + throw ActivityServiceRemote.ResponseError.decodingFailure + } + return try ActivityGroup(key, dictionary: group) + } + + return groups + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote_ApiVersion1_0.swift b/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote_ApiVersion1_0.swift new file mode 100644 index 000000000000..2aa4ac2bf0d5 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ActivityServiceRemote_ApiVersion1_0.swift @@ -0,0 +1,46 @@ +@objc public class ActivityServiceRemote_ApiVersion1_0: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Makes a request to Restore a site to a previous state. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - rewindID: The rewindID to restore to. + /// - types: The types of items to restore. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: A restoreID and jobID to check the status of the rewind request. + /// + public func restoreSite(_ siteID: Int, + rewindID: String, + types: JetpackRestoreTypes? = nil, + success: @escaping (_ restoreID: String, _ jobID: Int) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "activity-log/\(siteID)/rewind/to/\(rewindID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_0) + var parameters: [String: AnyObject] = [:] + + if let types = types { + parameters["types"] = types.toDictionary() as AnyObject + } + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let restoreID = responseDict["restore_id"] as? Int, + let jobID = responseDict["job_id"] as? Int else { + failure(ResponseError.decodingFailure) + return + } + success(String(restoreID), jobID) + }, + failure: { error, _ in + failure(error) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/AnnouncementServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/AnnouncementServiceRemote.swift new file mode 100644 index 000000000000..343680b379b8 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AnnouncementServiceRemote.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Retrieves feature announcements from the related endpoint +public class AnnouncementServiceRemote: ServiceRemoteWordPressComREST { + + public func getAnnouncements(appId: String, + appVersion: String, + locale: String, + completion: @escaping (Result<[Announcement], Error>) -> Void) { + + guard let endPoint = makeEndpoint(appId: appId, appVersion: appVersion, locale: locale) else { + completion(.failure(AnnouncementError.endpointError)) + return + } + + let path = self.path(forEndpoint: endPoint, withVersion: ._2_0) + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: path, type: AnnouncementsContainer.self) + .map { $0.body.announcements } + .eraseToError() + .execute(completion) + } + } +} + +// MARK: - Helpers +private extension AnnouncementServiceRemote { + + func makeQueryItems(appId: String, appVersion: String, locale: String) -> [URLQueryItem] { + return [URLQueryItem(name: Constants.appIdKey, value: appId), + URLQueryItem(name: Constants.appVersionKey, value: appVersion), + URLQueryItem(name: Constants.localeKey, value: locale)] + } + + func makeEndpoint(appId: String, appVersion: String, locale: String) -> String? { + var path = URLComponents(string: Constants.baseUrl) + path?.queryItems = makeQueryItems(appId: appId, appVersion: appVersion, locale: locale) + return path?.string + } +} + +// MARK: - Constants +private extension AnnouncementServiceRemote { + + enum Constants { + static let baseUrl = "mobile/feature-announcements/" + static let appIdKey = "app_id" + static let appVersionKey = "app_version" + static let localeKey = "_locale" + } + + enum AnnouncementError: Error { + case endpointError + + var localizedDescription: String { + switch self { + case .endpointError: + return NSLocalizedString("Invalid endpoint", + comment: "Error message generated when announcement service is unable to return a valid endpoint.") + } + } + } +} + +// MARK: - Decoded data +public struct AnnouncementsContainer: Decodable { + public let announcements: [Announcement] + + private enum CodingKeys: String, CodingKey { + case announcements = "announcements" + } + + public init(from decoder: Decoder) throws { + let rootContainer = try decoder.container(keyedBy: CodingKeys.self) + announcements = try rootContainer.decode([Announcement].self, forKey: .announcements) + } +} + +public struct Announcement: Codable { + public let appVersionName: String + public let minimumAppVersion: String + public let maximumAppVersion: String + public let appVersionTargets: [String] + public let detailsUrl: String + public let announcementVersion: String + public let isLocalized: Bool + public let responseLocale: String + public let features: [Feature] +} + +public struct Feature: Codable { + public let title: String + public let subtitle: String + public let icons: [FeatureIcon]? + public let iconUrl: String + public let iconBase64: String? +} + +public struct FeatureIcon: Codable { + public let iconUrl: String + public let iconBase64: String + public let iconType: String +} diff --git a/WordPressKit/Sources/WordPressKit/Services/AtomicAuthenticationServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/AtomicAuthenticationServiceRemote.swift new file mode 100644 index 000000000000..2b7d62ac2c9e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AtomicAuthenticationServiceRemote.swift @@ -0,0 +1,82 @@ +import Foundation + +public class AtomicAuthenticationServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case responseIsNotADictionary(response: Any) + case decodingFailure(response: [String: AnyObject]) + case couldNotInstantiateCookie(name: String, value: String, domain: String, path: String, expires: Date) + } + + public func getAuthCookie( + siteID: Int, + success: @escaping (_ cookie: HTTPCookie) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/atomic-auth-proxy/read-access-cookies" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, _ in + do { + let settings = try self.cookie(from: responseObject) + success(settings) + } catch { + failure(error) + } + }, + failure: { error, _ in + failure(error) + }) + } + + // MARK: - Result Parsing + + private func date(from expiration: Int) -> Date { + return Date(timeIntervalSince1970: TimeInterval(expiration)) + } + + private func cookie(from responseObject: Any) throws -> HTTPCookie { + guard let response = responseObject as? [String: AnyObject] else { + let error = ResponseError.responseIsNotADictionary(response: responseObject) + WPKitLogError("❗️Error: \(error)") + throw error + } + + guard let cookies = response["cookies"] as? [[String: Any]] else { + let error = ResponseError.decodingFailure(response: response) + WPKitLogError("❗️Error: \(error)") + throw error + } + + let cookieDictionary = cookies[0] + + guard let name = cookieDictionary["name"] as? String, + let value = cookieDictionary["value"] as? String, + let domain = cookieDictionary["domain"] as? String, + let path = cookieDictionary["path"] as? String, + let expires = cookieDictionary["expires"] as? Int else { + + let error = ResponseError.decodingFailure(response: response) + WPKitLogError("❗️Error: \(error)") + throw error + } + + let expirationDate = date(from: expires) + + guard let cookie = HTTPCookie(properties: [ + .name: name, + .value: value, + .domain: domain, + .path: path, + .expires: expirationDate + ]) else { + let error = ResponseError.couldNotInstantiateCookie(name: name, value: value, domain: domain, path: path, expires: expirationDate) + WPKitLogError("❗️Error: \(error)") + throw error + } + + return cookie + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/AtomicSiteServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/AtomicSiteServiceRemote.swift new file mode 100644 index 000000000000..c6eecfdf6bc9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AtomicSiteServiceRemote.swift @@ -0,0 +1,89 @@ +import Foundation + +public final class AtomicSiteServiceRemote: ServiceRemoteWordPressComREST { + /// - parameter scrollID: Pass the scroll ID from the previous response to + /// fetch the next page. + public func getErrorLogs(siteID: Int, + range: Range, + severity: AtomicErrorLogEntry.Severity? = nil, + scrollID: String? = nil, + pageSize: Int = 50, + success: @escaping (AtomicErrorLogsResponse) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/hosting/error-logs/", withVersion: ._2_0) + var parameters = [ + "start": "\(Int(range.lowerBound.timeIntervalSince1970))", + "end": "\(Int(range.upperBound.timeIntervalSince1970))", + "sort_order": "desc", + "page_size": "\(pageSize)" + ] as [String: String] + if let severity { + parameters["filter[severity][]"] = severity.rawValue + } + if let scrollID { + parameters["scroll_id"] = scrollID + } + wordPressComRESTAPI.get(path, parameters: parameters as [String: AnyObject]) { responseObject, httpResponse in + guard (200..<300).contains(httpResponse?.statusCode ?? 0), + let data = (responseObject as? [String: AnyObject])?["data"], + JSONSerialization.isValidJSONObject(data) else { + failure(URLError(.unknown)) + return + } + do { + let data = try JSONSerialization.data(withJSONObject: data) + let response = try JSONDecoder.apiDecoder.decode(AtomicErrorLogsResponse.self, from: data) + success(response) + } catch { + WPKitLogError("Error parsing campaigns response: \(error), \(responseObject)") + failure(error) + } + } failure: { error, _ in + failure(error) + } + } + + public func getWebServerLogs(siteID: Int, + range: Range, + httpMethod: String? = nil, + statusCode: Int? = nil, + scrollID: String? = nil, + pageSize: Int = 50, + success: @escaping (AtomicWebServerLogsResponse) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/hosting/logs/", withVersion: ._2_0) + var parameters = [ + "start": "\(Int(range.lowerBound.timeIntervalSince1970))", + "end": "\(Int(range.upperBound.timeIntervalSince1970))", + "sort_order": "desc", + "page_size": "\(pageSize)" + ] as [String: String] + if let httpMethod { + parameters["filter[request_type][]"] = httpMethod.uppercased() + } + if let statusCode { + parameters["filter[status][]"] = "\(statusCode)" + } + if let scrollID { + parameters["scroll_id"] = scrollID + } + wordPressComRESTAPI.get(path, parameters: parameters as [String: AnyObject]) { responseObject, httpResponse in + guard (200..<300).contains(httpResponse?.statusCode ?? 0), + let data = (responseObject as? [String: AnyObject])?["data"], + JSONSerialization.isValidJSONObject(data) else { + failure(URLError(.unknown)) + return + } + do { + let data = try JSONSerialization.data(withJSONObject: data) + let response = try JSONDecoder.apiDecoder.decode(AtomicWebServerLogsResponse.self, from: data) + success(response) + } catch { + WPKitLogError("Error parsing campaigns response: \(error), \(responseObject)") + failure(error) + } + } failure: { error, _ in + failure(error) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/AutomatedTransferService.swift b/WordPressKit/Sources/WordPressKit/Services/AutomatedTransferService.swift new file mode 100644 index 000000000000..168783e89037 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/AutomatedTransferService.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Class encapsualting all requests related to performing Automated Transfer operations. +public class AutomatedTransferService: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + public enum AutomatedTransferEligibilityError: Error { + case unverifiedEmail + case excessiveDiskSpaceUsage + case noBusinessPlan + case VIPSite + case notAdmin + case notDomainOwner + case noCustomDomain + case greylistedSite + case privateSite + case unknown + } + + public func checkTransferEligibility(siteID: Int, + success: @escaping () -> Void, + failure: @escaping (AutomatedTransferEligibilityError) -> Void) { + let endpoint = "sites/\(siteID)/automated-transfers/eligibility" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(.unknown) + return + } + + guard let isEligible = response["is_eligible"] as? Bool, isEligible == true else { + failure(self.eligibilityError(from: response)) + return + } + + success() + }, failure: { _, _ in + failure(.unknown) + }) + } + + public typealias AutomatedTransferInitationResponse = (transferID: Int, status: AutomatedTransferStatus) + public func initiateAutomatedTransfer(siteID: Int, + pluginSlug: String, + success: @escaping (AutomatedTransferInitationResponse) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/automated-transfers/initiate" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let payload = ["plugin": pluginSlug] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: payload, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + guard let transferID = response["transfer_id"] as? Int, + let status = response["status"] as? String, + let statusObject = AutomatedTransferStatus(status: status) else { + failure(ResponseError.decodingFailure) + return + } + + success((transferID: transferID, status: statusObject)) + }) { (error, _) in + failure(error) + } + + } + + public func fetchAutomatedTransferStatus(siteID: Int, + success: @escaping (AutomatedTransferStatus) -> Void, + failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/automated-transfers/status" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + guard let status = response["status"] as? String, + let currentStep = response["step"] as? Int, + let totalSteps = response["total"] as? Int, + let statusObject = AutomatedTransferStatus(status: status, step: currentStep, totalSteps: totalSteps) else { + failure(ResponseError.decodingFailure) + return + } + + success(statusObject) + }) { (error, _) in + failure(error) + } + + } + + private func eligibilityError(from response: [String: AnyObject]) -> AutomatedTransferEligibilityError { + guard let errors = response["errors"] as? [[String: AnyObject]], + let errorType = errors.first?["code"] as? String else { + // The API can potentially return multiple errors here. Since there isn't really an actionable + // way for user to deal with multiple of them at once, we're just picking the first one. + return .unknown + } + + switch errorType { + case "email_unverified": + return .unverifiedEmail + case "excessive_disk_space": + return .excessiveDiskSpaceUsage + case "no_business_plan": + return .noBusinessPlan + case "no_vip_sites": + return .VIPSite + case "non_admin_user": + return .notAdmin + case "not_domain_owner": + return .notDomainOwner + case "not_using_custom_domain": + return .noCustomDomain + case "site_graylisted": + return .greylistedSite + case "site_private": + return .privateSite + default: + return .unknown + } + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/BlazeServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/BlazeServiceRemote.swift new file mode 100644 index 000000000000..5ed721f13b1b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlazeServiceRemote.swift @@ -0,0 +1,29 @@ +import Foundation + +open class BlazeServiceRemote: ServiceRemoteWordPressComREST { + + // MARK: - Campaigns + + /// Searches the campaigns for the site with the given ID. The campaigns are returned ordered by the post date. + /// + /// - parameters: + /// - siteId: The site ID. + /// - page: The response page. By default, returns the first page. + open func searchCampaigns(forSiteId siteId: Int, page: Int = 1, callback: @escaping (Result) -> Void) { + let endpoint = "sites/\(siteId)/wordads/dsp/api/v1/search/campaigns/site/\(siteId)" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + Task { @MainActor in + let result = await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: ["page": page] as [String: AnyObject], + jsonDecoder: JSONDecoder.apiDecoder, + type: BlazeCampaignsSearchResponse.self + ) + .map { $0.body } + .eraseToError() + callback(result) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/BlockEditorSettingsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/BlockEditorSettingsServiceRemote.swift new file mode 100644 index 000000000000..d8ddd37d6613 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlockEditorSettingsServiceRemote.swift @@ -0,0 +1,45 @@ +import Foundation + +public class BlockEditorSettingsServiceRemote { + let remoteAPI: WordPressOrgRestApi + public init(remoteAPI: WordPressOrgRestApi) { + self.remoteAPI = remoteAPI + } +} + +// MARK: Editor `theme_supports` support +public extension BlockEditorSettingsServiceRemote { + typealias EditorThemeCompletionHandler = (Swift.Result) -> Void + + func fetchTheme(completion: @escaping EditorThemeCompletionHandler) { + let requestPath = "/wp/v2/themes" + let parameters = ["status": "active"] + Task { @MainActor in + let result = await self.remoteAPI.get(path: requestPath, parameters: parameters, type: [RemoteEditorTheme].self) + .map { $0.first } + .mapError { error -> Error in error } + completion(result) + } + } + +} + +// MARK: Editor Global Styles support +public extension BlockEditorSettingsServiceRemote { + typealias BlockEditorSettingsCompletionHandler = (Swift.Result) -> Void + + func fetchBlockEditorSettings(completion: @escaping BlockEditorSettingsCompletionHandler) { + Task { @MainActor in + let result = await self.remoteAPI.get(path: "/wp-block-editor/v1/settings", parameters: ["context": "mobile"], type: RemoteBlockEditorSettings.self) + .map { settings -> RemoteBlockEditorSettings? in settings } + .flatMapError { original in + if case let .unparsableResponse(response, _, underlyingError) = original, response?.statusCode == 200, underlyingError is DecodingError { + return .success(nil) + } + return .failure(original) + } + .mapError { error -> Error in error } + completion(result) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogJetpackSettingsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/BlogJetpackSettingsServiceRemote.swift new file mode 100644 index 000000000000..7ac03196acfe --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogJetpackSettingsServiceRemote.swift @@ -0,0 +1,263 @@ +import Foundation +import WordPressShared + +public class BlogJetpackSettingsServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Fetches the Jetpack settings for the specified site + /// + public func getJetpackSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)/rest-api" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: Any] = ["path": "/jetpack/v4/settings"] + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let results = responseDict["data"] as? [String: AnyObject], + let remoteSettings = try? self.remoteJetpackSettingsFromDictionary(results) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetches the Jetpack Monitor settings for the specified site + /// + public func getJetpackMonitorSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackMonitorSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let results = responseDict["settings"] as? [String: AnyObject], + let remoteMonitorSettings = try? self.remoteJetpackMonitorSettingsFromDictionary(results) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteMonitorSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetches the Jetpack Modules settings for the specified site + /// + public func getJetpackModulesSettingsForSite(_ siteID: Int, success: @escaping (RemoteBlogJetpackModulesSettings) -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "sites/\(siteID)/jetpack/modules" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let responseDict = response as? [String: Any], + let modules = responseDict["modules"] as? [[String: AnyObject]], + let remoteModulesSettings = try? self.remoteJetpackModulesSettingsFromArray(modules) else { + failure(ResponseError.decodingFailure) + return + } + success(remoteModulesSettings) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack settings for the specified site + /// + public func updateJetpackSettingsForSite(_ siteID: Int, settings: RemoteBlogJetpackSettings, success: @escaping () -> Void, failure: @escaping (Error?) -> Void) { + + let dictionary = dictionaryFromJetpackSettings(settings) + guard let jSONData = try? JSONSerialization.data(withJSONObject: dictionary, options: []), + let jSONBody = String(data: jSONData, encoding: .ascii) else { + failure(nil) + return + } + + let endpoint = "jetpack-blogs/\(siteID)/rest-api" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["path": "/jetpack/v4/settings", + "body": jSONBody, + "json": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack Monitor settings for the specified site + /// + public func updateJetpackMonitorSettingsForSite(_ siteID: Int, settings: RemoteBlogJetpackMonitorSettings, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + + let endpoint = "jetpack-blogs/\(siteID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = dictionaryFromJetpackMonitorSettings(settings) + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Saves the Jetpack Module active setting for the specified site + /// + public func updateJetpackModuleActiveSettingForSite(_ siteID: Int, module: String, active: Bool, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/jetpack/modules/\(module)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [ModuleOptionKeys.active: active] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject], + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + + /// Disconnects Jetpack from a site + /// + @objc public func disconnectJetpackFromSite(_ siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "jetpack-blogs/\(siteID)/mine/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { + _, _ in + success() + }, failure: { + error, _ in + failure(error) + }) + } + +} + +private extension BlogJetpackSettingsServiceRemote { + + func remoteJetpackSettingsFromDictionary(_ dictionary: [String: AnyObject]) throws -> RemoteBlogJetpackSettings { + + guard let monitorEnabled = dictionary[Keys.monitorEnabled] as? Bool, + let blockMaliciousLoginAttempts = dictionary[Keys.blockMaliciousLoginAttempts] as? Bool, + let allowlistedIPs = dictionary[Keys.allowListedIPAddresses]?[Keys.allowListedIPsLocal] as? [String], + let ssoEnabled = dictionary[Keys.ssoEnabled] as? Bool, + let ssoMatchAccountsByEmail = dictionary[Keys.ssoMatchAccountsByEmail] as? Bool, + let ssoRequireTwoStepAuthentication = dictionary[Keys.ssoRequireTwoStepAuthentication] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackSettings(monitorEnabled: monitorEnabled, + blockMaliciousLoginAttempts: blockMaliciousLoginAttempts, + loginAllowListedIPAddresses: Set(allowlistedIPs), + ssoEnabled: ssoEnabled, + ssoMatchAccountsByEmail: ssoMatchAccountsByEmail, + ssoRequireTwoStepAuthentication: ssoRequireTwoStepAuthentication) + } + + func remoteJetpackMonitorSettingsFromDictionary(_ dictionary: [String: AnyObject]) throws -> RemoteBlogJetpackMonitorSettings { + + guard let monitorEmailNotifications = dictionary[Keys.monitorEmailNotifications] as? Bool, + let monitorPushNotifications = dictionary[Keys.monitorPushNotifications] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackMonitorSettings(monitorEmailNotifications: monitorEmailNotifications, + monitorPushNotifications: monitorPushNotifications) + } + + func remoteJetpackModulesSettingsFromArray(_ modules: [[String: AnyObject]]) throws -> RemoteBlogJetpackModulesSettings { + let dictionary = modules.reduce(into: [String: [String: AnyObject]]()) { + guard let key = $1.valueAsString(forKey: "id") else { + return + } + $0[key] = $1 + } + + guard let lazyLoadImagesValue = dictionary[Keys.lazyLoadImages]?[ModuleOptionKeys.active] as? Bool, + let serveImagesFromOurServersValue = dictionary[Keys.serveImagesFromOurServers]?[ModuleOptionKeys.active] as? Bool else { + throw ResponseError.decodingFailure + } + + return RemoteBlogJetpackModulesSettings(lazyLoadImages: lazyLoadImagesValue, + serveImagesFromOurServers: serveImagesFromOurServersValue) + } + + func dictionaryFromJetpackSettings(_ settings: RemoteBlogJetpackSettings) -> [String: Any] { + let joinedIPs = settings.loginAllowListedIPAddresses.joined(separator: ", ") + let shouldSendAllowlist = settings.blockMaliciousLoginAttempts + let settingsDictionary: [String: Any?] = [ + Keys.monitorEnabled: settings.monitorEnabled, + Keys.blockMaliciousLoginAttempts: settings.blockMaliciousLoginAttempts, + Keys.allowListedIPAddresses: shouldSendAllowlist ? joinedIPs : nil, + Keys.ssoEnabled: settings.ssoEnabled, + Keys.ssoMatchAccountsByEmail: settings.ssoMatchAccountsByEmail, + Keys.ssoRequireTwoStepAuthentication: settings.ssoRequireTwoStepAuthentication + ] + return settingsDictionary.compactMapValues { $0 } + } + + func dictionaryFromJetpackMonitorSettings(_ settings: RemoteBlogJetpackMonitorSettings) -> [String: AnyObject] { + + return [Keys.monitorEmailNotifications: settings.monitorEmailNotifications as AnyObject, + Keys.monitorPushNotifications: settings.monitorPushNotifications as AnyObject] + } +} + +public extension BlogJetpackSettingsServiceRemote { + + enum Keys { + + // RemoteBlogJetpackSettings keys + public static let monitorEnabled = "monitor" + public static let blockMaliciousLoginAttempts = "protect" + public static let allowListedIPAddresses = "jetpack_protect_global_whitelist" + public static let allowListedIPsLocal = "local" + public static let ssoEnabled = "sso" + public static let ssoMatchAccountsByEmail = "jetpack_sso_match_by_email" + public static let ssoRequireTwoStepAuthentication = "jetpack_sso_require_two_step" + + // RemoteBlogJetpackMonitorSettings keys + static let monitorEmailNotifications = "email_notifications" + static let monitorPushNotifications = "wp_note_notifications" + + // RemoteBlogJetpackModuleSettings keys + public static let lazyLoadImages = "lazy-images" + public static let serveImagesFromOurServers = "photon" + + } + + enum ModuleOptionKeys { + + // Whether or not the module is currently active + public static let active = "active" + + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemote.h new file mode 100644 index 000000000000..23c9e9a5d924 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemote.h @@ -0,0 +1,43 @@ +#import + +@class RemoteBlog; +@class RemoteBlogSettings; +@class RemotePostType; +@class RemoteUser; + +typedef void (^PostTypesHandler)(NSArray *postTypes); +typedef void (^PostFormatsHandler)(NSDictionary *postFormats); +typedef void (^UsersHandler)(NSArray *users); +typedef void (^MultiAuthorCheckHandler)(BOOL isMultiAuthor); +typedef void (^SuccessHandler)(void); + +@protocol BlogServiceRemote + +/** + Synchronizes all blog's authors. + + @param success The block that will be executed on success. Can be nil. + @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's post types. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's post formats. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.h new file mode 100644 index 000000000000..e7d284f25f20 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.h @@ -0,0 +1,69 @@ +#import +#import +#import + +typedef void (^BlogDetailsHandler)(RemoteBlog *remoteBlog); +typedef void (^SettingsHandler)(RemoteBlogSettings *settings); + +@interface BlogServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + * @brief Synchronizes a blog and its top-level details. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogWithSuccess:(BlogDetailsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Synchronizes a blog's settings. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogSettingsWithSuccess:(SettingsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates the blog settings. + * + * @note Requires WPCOM/Jetpack APIs. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogSettings:(RemoteBlogSettings *)remoteBlogSettings + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; + + +/** + * @brief Fetch site info for the specified site address. + * + * @note Uses anonymous API + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)fetchSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Fetch site info (does not require authentication) for the specified site address. + * + * @note Uses anonymous API + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)fetchUnauthenticatedSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.m new file mode 100644 index 000000000000..b8b3f140ee0c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteREST.m @@ -0,0 +1,505 @@ +#import +#import "BlogServiceRemoteREST.h" +#import "NSMutableDictionary+Helpers.h" +#import "RemotePostType.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +#pragma mark - Parsing Keys +static NSString * const RemoteBlogNameKey = @"name"; +static NSString * const RemoteBlogTaglineKey = @"description"; +static NSString * const RemoteBlogPrivacyKey = @"blog_public"; +static NSString * const RemoteBlogLanguageKey = @"lang_id"; +static NSString * const RemoteBlogIconKey = @"site_icon"; +static NSString * const RemoteBlogGMTOffsetKey = @"gmt_offset"; +static NSString * const RemoteBlogTimezoneStringKey = @"timezone_string"; + +static NSString * const RemoteBlogSettingsKey = @"settings"; +static NSString * const RemoteBlogDefaultCategoryKey = @"default_category"; +static NSString * const RemoteBlogDefaultPostFormatKey = @"default_post_format"; +static NSString * const RemoteBlogDateFormatKey = @"date_format"; +static NSString * const RemoteBlogTimeFormatKey = @"time_format"; +static NSString * const RemoteBlogStartOfWeekKey = @"start_of_week"; +static NSString * const RemoteBlogPostsPerPageKey = @"posts_per_page"; +static NSString * const RemoteBlogCommentsAllowedKey = @"default_comment_status"; +static NSString * const RemoteBlogCommentsBlocklistKeys = @"blacklist_keys"; +static NSString * const RemoteBlogCommentsCloseAutomaticallyKey = @"close_comments_for_old_posts"; +static NSString * const RemoteBlogCommentsCloseAutomaticallyAfterDaysKey = @"close_comments_days_old"; +static NSString * const RemoteBlogCommentsKnownUsersAllowlistKey = @"comment_whitelist"; +static NSString * const RemoteBlogCommentsMaxLinksKey = @"comment_max_links"; +static NSString * const RemoteBlogCommentsModerationKeys = @"moderation_keys"; +static NSString * const RemoteBlogCommentsPagingEnabledKey = @"page_comments"; +static NSString * const RemoteBlogCommentsPageSizeKey = @"comments_per_page"; +static NSString * const RemoteBlogCommentsRequireModerationKey = @"comment_moderation"; +static NSString * const RemoteBlogCommentsRequireNameAndEmailKey = @"require_name_email"; +static NSString * const RemoteBlogCommentsRequireRegistrationKey = @"comment_registration"; +static NSString * const RemoteBlogCommentsSortOrderKey = @"comment_order"; +static NSString * const RemoteBlogCommentsThreadingEnabledKey = @"thread_comments"; +static NSString * const RemoteBlogCommentsThreadingDepthKey = @"thread_comments_depth"; +static NSString * const RemoteBlogCommentsPingbackOutboundKey = @"default_pingback_flag"; +static NSString * const RemoteBlogCommentsPingbackInboundKey = @"default_ping_status"; +static NSString * const RemoteBlogRelatedPostsAllowedKey = @"jetpack_relatedposts_allowed"; +static NSString * const RemoteBlogRelatedPostsEnabledKey = @"jetpack_relatedposts_enabled"; +static NSString * const RemoteBlogRelatedPostsShowHeadlineKey = @"jetpack_relatedposts_show_headline"; +static NSString * const RemoteBlogRelatedPostsShowThumbnailsKey = @"jetpack_relatedposts_show_thumbnails"; +static NSString * const RemoteBlogAmpSupportedKey = @"amp_is_supported"; +static NSString * const RemoteBlogAmpEnabledKey = @"amp_is_enabled"; + +static NSString * const RemoteBlogSharingButtonStyle = @"sharing_button_style"; +static NSString * const RemoteBlogSharingLabel = @"sharing_label"; +static NSString * const RemoteBlogSharingTwitterName = @"twitter_via"; +static NSString * const RemoteBlogSharingCommentLikesEnabled = @"jetpack_comment_likes_enabled"; +static NSString * const RemoteBlogSharingDisabledLikes = @"disabled_likes"; +static NSString * const RemoteBlogSharingDisabledReblogs = @"disabled_reblogs"; + +static NSString * const RemotePostTypesKey = @"post_types"; +static NSString * const RemotePostTypeNameKey = @"name"; +static NSString * const RemotePostTypeLabelKey = @"label"; +static NSString * const RemotePostTypeQueryableKey = @"api_queryable"; + +#pragma mark - Keys used for Update Calls +// Note: Only god knows why these don't match the "Parsing Keys" +static NSString * const RemoteBlogNameForUpdateKey = @"blogname"; +static NSString * const RemoteBlogTaglineForUpdateKey = @"blogdescription"; + +#pragma mark - Defaults +static NSString * const RemoteBlogDefaultPostFormat = @"standard"; +static NSInteger const RemoteBlogUncategorizedCategory = 1; + + + +@implementation BlogServiceRemoteREST + +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + [self getAllAuthorsWithRemoteUsers:nil + offset:nil + success:success + failure:failure]; +} + +/** + This method is called recursively to fetch all authors. + The success block is called whenever the response users array is nil or empty. + + @param remoteUsers The loaded remote users + @param offset The first n users to be skipped in the returned array + @param success The block that will be executed on success + @param failure The block that will be executed on failure + */ +- (void)getAllAuthorsWithRemoteUsers:(NSMutableArray *)remoteUsers + offset:(NSNumber *)offset + success:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *parameters = [@{ @"authors_only":@(YES), + @"number": @(100) + } mutableCopy]; + + if ([offset wp_isValidObject]) { + parameters[@"offset"] = offset.stringValue; + } + + NSString *path = [self pathForUsers]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *responseUsers = responseObject[@"users"]; + + NSMutableArray *users = [remoteUsers wp_isValidObject] ? [remoteUsers mutableCopy] : [NSMutableArray array]; + + if (![responseUsers wp_isValidObject] || responseUsers.count == 0) { + success([users copy]); + } else { + [users addObjectsFromArray:[self usersFromJSONArray:responseUsers]]; + [self getAllAuthorsWithRemoteUsers:users + offset:@(users.count) + success:success + failure:failure]; + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForPostTypes]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + NSDictionary *parameters = @{@"context": @"edit"}; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(NSDictionary *responseObject, NSHTTPURLResponse *httpResponse) { + + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + NSArray *postTypes = [[responseObject arrayForKey:RemotePostTypesKey] wp_map:^id(NSDictionary *json) { + return [self remotePostTypeWithDictionary:json]; + }]; + if (!postTypes.count) { + WPKitLogError(@"Response to %@ did not include post types for site.", requestUrl); + failure(nil); + return; + } + if (success) { + success(postTypes); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForPostFormats]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *formats = [self mapPostFormatsFromResponse:responseObject[@"formats"]]; + if (success) { + success(formats); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncBlogWithSuccess:(BlogDetailsHandler)success + failure:(void (^)(NSError *))failure +{ + NSString *path = [self pathForSite]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *responseDict = (NSDictionary *)responseObject; + RemoteBlog *remoteBlog = [[RemoteBlog alloc] initWithJSONDictionary:responseDict]; + if (success) { + success(remoteBlog); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncBlogSettingsWithSuccess:(SettingsHandler)success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForSettings]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]){ + if (failure) { + failure(nil); + } + return; + } + RemoteBlogSettings *remoteSettings = [self remoteBlogSettingFromJSONDictionary:responseObject]; + if (success) { + success(remoteSettings); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogSettings:(RemoteBlogSettings *)settings + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; +{ + NSParameterAssert(settings); + + NSDictionary *parameters = [self remoteSettingsToDictionary:settings]; + NSString *path = [NSString stringWithFormat:@"sites/%@/settings?context=edit", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(NSDictionary *responseDict, NSHTTPURLResponse *httpResponse) { + if (![responseDict isKindOfClass:[NSDictionary class]]) { + if (failure) { + failure(nil); + } + return; + } + if (!responseDict[@"updated"]) { + if (failure) { + failure(nil); + } + } else if (success) { + success(); + } + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@", siteAddress]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success((NSDictionary *)responseObject); + return; + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchUnauthenticatedSiteInfoForAddress:(NSString *)siteAddress + success:(void(^)(NSDictionary *siteInfoDict))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self pathForEndpoint:@"connect/site-info" withVersion:WordPressComRESTAPIVersion_1_1]; + NSURL *siteURL = [NSURL URLWithString:siteAddress]; + + [self.wordPressComRESTAPI get:path + parameters:@{ @"url": siteURL.absoluteString } + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success((NSDictionary *)responseObject); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if(failure) { + failure(error); + } + }]; +} + +#pragma mark - API paths + +- (NSString *)pathForUsers +{ + return [NSString stringWithFormat:@"sites/%@/users", self.siteID]; +} + +- (NSString *)pathForSite +{ + return [NSString stringWithFormat:@"sites/%@", self.siteID]; +} + +- (NSString *)pathForPostTypes +{ + return [NSString stringWithFormat:@"sites/%@/post-types", self.siteID]; +} + +- (NSString *)pathForPostFormats +{ + return [NSString stringWithFormat:@"sites/%@/post-formats", self.siteID]; +} + +- (NSString *)pathForSettings +{ + return [NSString stringWithFormat:@"sites/%@/settings", self.siteID]; +} + + +#pragma mark - Mapping methods + +- (NSArray *)usersFromJSONArray:(NSArray *)jsonUsers +{ + return [jsonUsers wp_map:^RemoteUser *(NSDictionary *jsonUser) { + return [self userFromJSONDictionary:jsonUser]; + }]; +} + +- (RemoteUser *)userFromJSONDictionary:(NSDictionary *)jsonUser +{ + RemoteUser *user = [RemoteUser new]; + user.userID = jsonUser[@"ID"]; + user.username = jsonUser[@"login"]; + user.email = jsonUser[@"email"]; + user.displayName = jsonUser[@"name"]; + user.primaryBlogID = jsonUser[@"site_ID"]; + user.avatarURL = jsonUser[@"avatar_URL"]; + user.linkedUserID = jsonUser[@"linked_user_ID"]; + return user; +} + +- (NSDictionary *)mapPostFormatsFromResponse:(id)response +{ + if ([response isKindOfClass:[NSDictionary class]]) { + return response; + } else { + return @{}; + } +} + +- (RemotePostType *)remotePostTypeWithDictionary:(NSDictionary *)json +{ + RemotePostType *postType = [[RemotePostType alloc] init]; + postType.name = [json stringForKey:RemotePostTypeNameKey]; + postType.label = [json stringForKey:RemotePostTypeLabelKey]; + postType.apiQueryable = [json numberForKey:RemotePostTypeQueryableKey]; + return postType; +} + +- (RemoteBlogSettings *)remoteBlogSettingFromJSONDictionary:(NSDictionary *)json +{ + NSAssert([json isKindOfClass:[NSDictionary class]], @"Invalid Settings Kind"); + + RemoteBlogSettings *settings = [RemoteBlogSettings new]; + NSDictionary *rawSettings = [json dictionaryForKey:RemoteBlogSettingsKey]; + + // General + settings.name = [json stringForKey:RemoteBlogNameKey]; + settings.tagline = [json stringForKey:RemoteBlogTaglineKey]; + settings.privacy = [rawSettings numberForKey:RemoteBlogPrivacyKey]; + settings.languageID = [rawSettings numberForKey:RemoteBlogLanguageKey]; + settings.iconMediaID = [rawSettings numberForKey:RemoteBlogIconKey]; + settings.gmtOffset = [rawSettings numberForKey:RemoteBlogGMTOffsetKey]; + settings.timezoneString = [rawSettings stringForKey:RemoteBlogTimezoneStringKey]; + + // Writing + settings.defaultCategoryID = [rawSettings numberForKey:RemoteBlogDefaultCategoryKey] ?: @(RemoteBlogUncategorizedCategory); + + // Note: the backend might send '0' as a number, OR a string value. Ref. Issue #4187 + if ([[rawSettings numberForKey:RemoteBlogDefaultPostFormatKey] isEqualToNumber:@(0)] || + [[rawSettings stringForKey:RemoteBlogDefaultPostFormatKey] isEqualToString:@"0"]) + { + settings.defaultPostFormat = RemoteBlogDefaultPostFormat; + } else { + settings.defaultPostFormat = [rawSettings stringForKey:RemoteBlogDefaultPostFormatKey]; + } + settings.dateFormat = [rawSettings stringForKey:RemoteBlogDateFormatKey]; + settings.timeFormat = [rawSettings stringForKey:RemoteBlogTimeFormatKey]; + settings.startOfWeek = [rawSettings stringForKey:RemoteBlogStartOfWeekKey]; + settings.postsPerPage = [rawSettings numberForKey:RemoteBlogPostsPerPageKey]; + + // Discussion + settings.commentsAllowed = [rawSettings numberForKey:RemoteBlogCommentsAllowedKey]; + settings.commentsBlocklistKeys = [rawSettings stringForKey:RemoteBlogCommentsBlocklistKeys]; + settings.commentsCloseAutomatically = [rawSettings numberForKey:RemoteBlogCommentsCloseAutomaticallyKey]; + settings.commentsCloseAutomaticallyAfterDays = [rawSettings numberForKey:RemoteBlogCommentsCloseAutomaticallyAfterDaysKey]; + settings.commentsFromKnownUsersAllowlisted = [rawSettings numberForKey:RemoteBlogCommentsKnownUsersAllowlistKey]; + settings.commentsMaximumLinks = [rawSettings numberForKey:RemoteBlogCommentsMaxLinksKey]; + settings.commentsModerationKeys = [rawSettings stringForKey:RemoteBlogCommentsModerationKeys]; + settings.commentsPagingEnabled = [rawSettings numberForKey:RemoteBlogCommentsPagingEnabledKey]; + settings.commentsPageSize = [rawSettings numberForKey:RemoteBlogCommentsPageSizeKey]; + settings.commentsRequireManualModeration = [rawSettings numberForKey:RemoteBlogCommentsRequireModerationKey]; + settings.commentsRequireNameAndEmail = [rawSettings numberForKey:RemoteBlogCommentsRequireNameAndEmailKey]; + settings.commentsRequireRegistration = [rawSettings numberForKey:RemoteBlogCommentsRequireRegistrationKey]; + settings.commentsSortOrder = [rawSettings stringForKey:RemoteBlogCommentsSortOrderKey]; + settings.commentsThreadingEnabled = [rawSettings numberForKey:RemoteBlogCommentsThreadingEnabledKey]; + settings.commentsThreadingDepth = [rawSettings numberForKey:RemoteBlogCommentsThreadingDepthKey]; + settings.pingbackOutboundEnabled = [rawSettings numberForKey:RemoteBlogCommentsPingbackOutboundKey]; + settings.pingbackInboundEnabled = [rawSettings numberForKey:RemoteBlogCommentsPingbackInboundKey]; + + // Related Posts + settings.relatedPostsAllowed = [rawSettings numberForKey:RemoteBlogRelatedPostsAllowedKey]; + settings.relatedPostsEnabled = [rawSettings numberForKey:RemoteBlogRelatedPostsEnabledKey]; + settings.relatedPostsShowHeadline = [rawSettings numberForKey:RemoteBlogRelatedPostsShowHeadlineKey]; + settings.relatedPostsShowThumbnails = [rawSettings numberForKey:RemoteBlogRelatedPostsShowThumbnailsKey]; + + // AMP + settings.ampSupported = [rawSettings numberForKey:RemoteBlogAmpSupportedKey]; + settings.ampEnabled = [rawSettings numberForKey:RemoteBlogAmpEnabledKey]; + + // Sharing + settings.sharingButtonStyle = [rawSettings stringForKey:RemoteBlogSharingButtonStyle]; + settings.sharingLabel = [rawSettings stringForKey:RemoteBlogSharingLabel]; + settings.sharingTwitterName = [rawSettings stringForKey:RemoteBlogSharingTwitterName]; + settings.sharingCommentLikesEnabled = [rawSettings numberForKey:RemoteBlogSharingCommentLikesEnabled]; + settings.sharingDisabledLikes = [rawSettings numberForKey:RemoteBlogSharingDisabledLikes]; + settings.sharingDisabledReblogs = [rawSettings numberForKey:RemoteBlogSharingDisabledReblogs]; + + return settings; +} + +- (NSDictionary *)remoteSettingsToDictionary:(RemoteBlogSettings *)settings +{ + NSParameterAssert(settings); + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + [parameters setValueIfNotNil:settings.name forKey:RemoteBlogNameForUpdateKey]; + [parameters setValueIfNotNil:settings.tagline forKey:RemoteBlogTaglineForUpdateKey]; + [parameters setValueIfNotNil:settings.privacy forKey:RemoteBlogPrivacyKey]; + [parameters setValueIfNotNil:settings.languageID forKey:RemoteBlogLanguageKey]; + [parameters setValueIfNotNil:settings.iconMediaID forKey:RemoteBlogIconKey]; + [parameters setValueIfNotNil:settings.gmtOffset forKey:RemoteBlogGMTOffsetKey]; + [parameters setValueIfNotNil:settings.timezoneString forKey:RemoteBlogTimezoneStringKey]; + + [parameters setValueIfNotNil:settings.defaultCategoryID forKey:RemoteBlogDefaultCategoryKey]; + [parameters setValueIfNotNil:settings.defaultPostFormat forKey:RemoteBlogDefaultPostFormatKey]; + [parameters setValueIfNotNil:settings.dateFormat forKey:RemoteBlogDateFormatKey]; + [parameters setValueIfNotNil:settings.timeFormat forKey:RemoteBlogTimeFormatKey]; + [parameters setValueIfNotNil:settings.startOfWeek forKey:RemoteBlogStartOfWeekKey]; + [parameters setValueIfNotNil:settings.postsPerPage forKey:RemoteBlogPostsPerPageKey]; + + [parameters setValueIfNotNil:settings.commentsAllowed forKey:RemoteBlogCommentsAllowedKey]; + [parameters setValueIfNotNil:settings.commentsBlocklistKeys forKey:RemoteBlogCommentsBlocklistKeys]; + [parameters setValueIfNotNil:settings.commentsCloseAutomatically forKey:RemoteBlogCommentsCloseAutomaticallyKey]; + [parameters setValueIfNotNil:settings.commentsCloseAutomaticallyAfterDays forKey:RemoteBlogCommentsCloseAutomaticallyAfterDaysKey]; + [parameters setValueIfNotNil:settings.commentsFromKnownUsersAllowlisted forKey:RemoteBlogCommentsKnownUsersAllowlistKey]; + [parameters setValueIfNotNil:settings.commentsMaximumLinks forKey:RemoteBlogCommentsMaxLinksKey]; + [parameters setValueIfNotNil:settings.commentsModerationKeys forKey:RemoteBlogCommentsModerationKeys]; + [parameters setValueIfNotNil:settings.commentsPagingEnabled forKey:RemoteBlogCommentsPagingEnabledKey]; + [parameters setValueIfNotNil:settings.commentsPageSize forKey:RemoteBlogCommentsPageSizeKey]; + [parameters setValueIfNotNil:settings.commentsRequireManualModeration forKey:RemoteBlogCommentsRequireModerationKey]; + [parameters setValueIfNotNil:settings.commentsRequireNameAndEmail forKey:RemoteBlogCommentsRequireNameAndEmailKey]; + [parameters setValueIfNotNil:settings.commentsRequireRegistration forKey:RemoteBlogCommentsRequireRegistrationKey]; + [parameters setValueIfNotNil:settings.commentsSortOrder forKey:RemoteBlogCommentsSortOrderKey]; + [parameters setValueIfNotNil:settings.commentsThreadingEnabled forKey:RemoteBlogCommentsThreadingEnabledKey]; + [parameters setValueIfNotNil:settings.commentsThreadingDepth forKey:RemoteBlogCommentsThreadingDepthKey]; + + [parameters setValueIfNotNil:settings.pingbackOutboundEnabled forKey:RemoteBlogCommentsPingbackOutboundKey]; + [parameters setValueIfNotNil:settings.pingbackInboundEnabled forKey:RemoteBlogCommentsPingbackInboundKey]; + + [parameters setValueIfNotNil:settings.relatedPostsEnabled forKey:RemoteBlogRelatedPostsEnabledKey]; + [parameters setValueIfNotNil:settings.relatedPostsShowHeadline forKey:RemoteBlogRelatedPostsShowHeadlineKey]; + [parameters setValueIfNotNil:settings.relatedPostsShowThumbnails forKey:RemoteBlogRelatedPostsShowThumbnailsKey]; + + [parameters setValueIfNotNil:settings.ampEnabled forKey:RemoteBlogAmpEnabledKey]; + + // Sharing + [parameters setValueIfNotNil:settings.sharingButtonStyle forKey:RemoteBlogSharingButtonStyle]; + [parameters setValueIfNotNil:settings.sharingLabel forKey:RemoteBlogSharingLabel]; + [parameters setValueIfNotNil:settings.sharingTwitterName forKey:RemoteBlogSharingTwitterName]; + [parameters setValueIfNotNil:settings.sharingCommentLikesEnabled forKey:RemoteBlogSharingCommentLikesEnabled]; + [parameters setValueIfNotNil:settings.sharingDisabledLikes forKey:RemoteBlogSharingDisabledLikes]; + [parameters setValueIfNotNil:settings.sharingDisabledReblogs forKey:RemoteBlogSharingDisabledReblogs]; + + return parameters; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..57a64cc14eaf --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.h @@ -0,0 +1,32 @@ +#import +#import +#import + +typedef void (^OptionsHandler)(NSDictionary *options); + +@interface BlogServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +/** + * @brief Synchronizes a blog's options. + * + * @note Available in XML-RPC only. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)syncBlogOptionsWithSuccess:(OptionsHandler)success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Update a blog's options. + * + * @note Available in XML-RPC only. + * + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updateBlogOptionsWith:(NSDictionary *)remoteBlogOptions + success:(SuccessHandler)success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..9870919dddb6 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BlogServiceRemoteXMLRPC.m @@ -0,0 +1,209 @@ +#import "BlogServiceRemoteXMLRPC.h" +#import "NSMutableDictionary+Helpers.h" +#import "RemotePostType.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +static NSString * const RemotePostTypeNameKey = @"name"; +static NSString * const RemotePostTypeLabelKey = @"label"; +static NSString * const RemotePostTypePublicKey = @"public"; + +@implementation BlogServiceRemoteXMLRPC + +- (void)getAllAuthorsWithSuccess:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + [self getAllAuthorsWithRemoteUsers:nil + offset:nil + success:success + failure:failure]; +} + + +/** + This method is called recursively to fetch all authors. + The success block is called whenever the response users array is nil or empty. + + @param remoteUsers The loaded remote users + @param offset The first n users to be skipped in the returned array + @param success The block that will be executed on success + @param failure The block that will be executed on failure + */ +- (void)getAllAuthorsWithRemoteUsers:(NSMutableArray *)remoteUsers + offset:(NSNumber *)offset + success:(UsersHandler)success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *filter = [@{ @"who":@"authors", + @"number": @(100) + } mutableCopy]; + + if ([offset wp_isValidObject]) { + filter[@"offset"] = offset.stringValue; + } + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:filter]; + [self.api callMethod:@"wp.getUsers" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSArray *responseUsers = [[responseObject allObjects] wp_map:^id(NSDictionary *xmlrpcUser) { + return [self remoteUserFromXMLRPCDictionary:xmlrpcUser]; + }]; + + NSMutableArray *users = [remoteUsers wp_isValidObject] ? [remoteUsers mutableCopy] : [NSMutableArray array]; + + if (success) { + if (![responseUsers wp_isValidObject] || responseUsers.count == 0) { + success([users copy]); + } else { + [users addObjectsFromArray:responseUsers]; + [self getAllAuthorsWithRemoteUsers:users + offset:@(users.count) + success:success + failure:failure]; + } + } + + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostTypesWithSuccess:(PostTypesHandler)success failure:(void (^)(NSError *error))failure +{ + NSArray *parameters = [self defaultXMLRPCArguments]; + [self.api callMethod:@"wp.getPostTypes" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + NSArray *postTypes = [[responseObject allObjects] wp_map:^id(NSDictionary *json) { + return [self remotePostTypeFromXMLRPCDictionary:json]; + }]; + if (!postTypes.count) { + WPKitLogError(@"Response to wp.getPostTypes did not include post types for site."); + failure(nil); + return; + } + if (success) { + success(postTypes); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing post types (%@): %@", response.URL, error); + + if (failure) { + failure(error); + } + }]; +} + +- (void)syncPostFormatsWithSuccess:(PostFormatsHandler)success failure:(void (^)(NSError *))failure +{ + NSDictionary *dict = @{@"show-supported": @"1"}; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:dict]; + + [self.api callMethod:@"wp.getPostFormats" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + + NSDictionary *postFormats = responseObject; + NSDictionary *respDict = responseObject; + if ([postFormats objectForKey:@"supported"]) { + NSMutableArray *supportedKeys; + if ([[postFormats objectForKey:@"supported"] isKindOfClass:[NSArray class]]) { + supportedKeys = [NSMutableArray arrayWithArray:[postFormats objectForKey:@"supported"]]; + } else if ([[postFormats objectForKey:@"supported"] isKindOfClass:[NSDictionary class]]) { + supportedKeys = [NSMutableArray arrayWithArray:[[postFormats objectForKey:@"supported"] allValues]]; + } + + // Standard isn't included in the list of supported formats? Maybe it will be one day? + if (![supportedKeys containsObject:@"standard"]) { + [supportedKeys addObject:@"standard"]; + } + + NSDictionary *allFormats = [postFormats objectForKey:@"all"]; + NSMutableArray *supportedValues = [NSMutableArray array]; + for (NSString *key in supportedKeys) { + [supportedValues addObject:[allFormats objectForKey:key]]; + } + respDict = [NSDictionary dictionaryWithObjects:supportedValues forKeys:supportedKeys]; + } + + if (success) { + success(respDict); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing post formats (%@): %@", response.URL, error); + + if (failure) { + failure(error); + } + }]; + +} + +- (void)syncBlogOptionsWithSuccess:(OptionsHandler)success failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self defaultXMLRPCArguments]; + [self.api callMethod:@"wp.getOptions" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + NSAssert([responseObject isKindOfClass:[NSDictionary class]], @"Response should be a dictionary."); + + if (success) { + success(responseObject); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error syncing blog options: %@", error); + + if (failure) { + failure(error); + } + }]; +} + +- (void)updateBlogOptionsWith:(NSDictionary *)remoteBlogOptions success:(SuccessHandler)success failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:remoteBlogOptions]; + [self.api callMethod:@"wp.setOptions" parameters:parameters success:^(id responseObject, NSHTTPURLResponse *response) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + if (failure) { + failure(nil); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + WPKitLogError(@"Error updating blog options: %@", error); + if (failure) { + failure(error); + } + }]; +} + +- (RemoteUser *)remoteUserFromXMLRPCDictionary:(NSDictionary *)xmlrpcUser +{ + RemoteUser *user = [RemoteUser new]; + user.userID = [xmlrpcUser numberForKey:@"user_id"]; + user.username = [xmlrpcUser stringForKey:@"username"]; + user.displayName = [xmlrpcUser stringForKey:@"display_name"]; + user.email = [xmlrpcUser stringForKey:@"email"]; + return user; +} + +- (RemotePostType *)remotePostTypeFromXMLRPCDictionary:(NSDictionary *)json +{ + RemotePostType *postType = [[RemotePostType alloc] init]; + postType.name = [json stringForKey:RemotePostTypeNameKey]; + postType.label = [json stringForKey:RemotePostTypeLabelKey]; + postType.apiQueryable = [json numberForKey:RemotePostTypePublicKey]; + return postType; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/BloggingPromptsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/BloggingPromptsServiceRemote.swift new file mode 100644 index 000000000000..af39889b099b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/BloggingPromptsServiceRemote.swift @@ -0,0 +1,151 @@ +/// Encapsulates logic to fetch blogging prompts from the remote endpoint. +/// +open class BloggingPromptsServiceRemote: ServiceRemoteWordPressComREST { + /// Used to format dates so the time information is omitted. + private static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .init(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + + return formatter + }() + + public enum RequestError: Error { + case encodingFailure + } + + /// Fetches a number of blogging prompts for the specified site. + /// Note that this method hits wpcom/v2, which means the `WordPressComRestAPI` needs to be initialized with `LocaleKeyV2`. + /// + /// - Parameters: + /// - siteID: Used to check which prompts have been answered for the site with given `siteID`. + /// - number: The number of prompts to query. When not specified, this will default to remote implementation. + /// - fromDate: When specified, this will fetch prompts from the given date. When not specified, this will default to remote implementation. + /// - completion: A closure that will be called when the fetch request completes. + open func fetchPrompts(for siteID: NSNumber, + number: Int? = nil, + fromDate: Date? = nil, + completion: @escaping (Result<[RemoteBloggingPrompt], Error>) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts", withVersion: ._2_0) + let requestParameter: [String: AnyHashable] = { + var params = [String: AnyHashable]() + + if let number = number, number > 0 { + params["number"] = number + } + + if let fromDate = fromDate { + // convert to yyyy-MM-dd format, excluding the timezone information. + // the date parameter doesn't need to be timezone-accurate since prompts are grouped by date. + params["from"] = Self.dateFormatter.string(from: fromDate) + } + + return params + }() + + let decoder = JSONDecoder.apiDecoder + // our API decoder assumes that we're converting from snake case. + // revert it to default so the CodingKeys match the actual response keys. + decoder.keyDecodingStrategy = .useDefaultKeys + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: requestParameter as [String: AnyObject], + jsonDecoder: decoder, + type: [String: [RemoteBloggingPrompt]].self + ) + .map { $0.body.values.first ?? [] } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + /// Fetches the blogging prompts settings for a given site. + /// + /// - Parameters: + /// - siteID: The site ID for the blogging prompts settings. + /// - completion: Closure that will be called when the request completes. + open func fetchSettings(for siteID: NSNumber, completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts/settings", withVersion: ._2_0) + Task { @MainActor in + await self.wordPressComRestApi.perform(.get, URLString: path, type: RemoteBloggingPromptsSettings.self) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + /// Updates the blogging prompts settings to remote. + /// + /// This will return an updated settings object if at least one of the fields is successfully modified. + /// If nothing has changed, it will still be regarded as a successful operation; but nil will be returned. + /// + /// - Parameters: + /// - siteID: The site ID of the blogging prompts settings. + /// - settings: The updated settings to upload. + /// - completion: Closure that will be called when the request completes. + open func updateSettings(for siteID: NSNumber, + with settings: RemoteBloggingPromptsSettings, + completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/blogging-prompts/settings", withVersion: ._2_0) + var parameters = [String: AnyObject]() + do { + let data = try JSONEncoder().encode(settings) + parameters = try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] ?? [:] + } catch { + completion(.failure(error)) + return + } + + // The parameter shouldn't be empty at this point. + // If by some chance it is, let's abort and return early. There could be something wrong with the parsing process. + guard !parameters.isEmpty else { + WPKitLogError("Error encoding RemoteBloggingPromptsSettings object: \(settings)") + completion(.failure(RequestError.encodingFailure)) + return + } + + wordPressComRESTAPI.post(path, parameters: parameters) { responseObject, _ in + do { + let data = try JSONSerialization.data(withJSONObject: responseObject) + let response = try JSONDecoder().decode(UpdateBloggingPromptsSettingsResponse.self, from: data) + completion(.success(response.updated)) + } catch { + completion(.failure(error)) + } + } failure: { error, _ in + completion(.failure(error)) + } + } +} + +// MARK: - Private helpers + +private extension BloggingPromptsServiceRemote { + /// An intermediate object representing the response structure after updating the prompts settings. + /// + /// If there is at least one updated field, the remote will return the full `RemoteBloggingPromptsSettings` object in the `updated` key. + /// Otherwise, if no fields are changed, the remote will assign an empty array to the `updated` key. + struct UpdateBloggingPromptsSettingsResponse: Decodable { + let updated: RemoteBloggingPromptsSettings? + + private enum CodingKeys: String, CodingKey { + case updated + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // return nil when no fields are changed. + if let _ = try? container.decode(Array.self, forKey: .updated) { + self.updated = nil + return + } + + self.updated = try container.decode(RemoteBloggingPromptsSettings.self, forKey: .updated) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemote.h new file mode 100644 index 000000000000..1621c57afcb0 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemote.h @@ -0,0 +1,69 @@ +#import +#import + + +// Used to determine which 'status' parameter to use when fetching Comments. +typedef enum { + CommentStatusFilterAll = 0, + CommentStatusFilterUnapproved, + CommentStatusFilterApproved, + CommentStatusFilterTrash, + CommentStatusFilterSpam, +} CommentStatusFilter; + + +@protocol CommentServiceRemote + +/** + Loads all of the comments associated with a blog + */ +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure; + + + +/** + Loads all of the comments associated with a blog + */ +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure; + + +/** + Loads the specified comment associated with a blog + */ +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError * error))failure; + +/** + Publishes a new comment + */ +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; +/** + Updates the content of an existing comment + */ +- (void)updateComment:(RemoteComment *)commen + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; + +/** + Updates the status of an existing comment + */ +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure; + +/** + Trashes a comment + */ +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST+ApiV2.swift b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST+ApiV2.swift new file mode 100644 index 000000000000..d62044486e69 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST+ApiV2.swift @@ -0,0 +1,65 @@ +public extension CommentServiceRemoteREST { + /// Lists the available keys for the request parameter. + enum RequestKeys: String { + /// The parent comment's ID. In API v2, supplying this parameter filters the list to only contain + /// the child/reply comments of the specified ID. + case parent + + /// The dotcom user ID of the comment author. In API v2, supplying this parameter filters the list + /// to only contain comments authored by the specified ID. + case author + + /// Valid values are `view`, `edit`, or `embed`. When not specified, the default context is `view`. + case context + } + + /// Retrieves a list of comments in a site with the specified siteID. + /// - Parameters: + /// - siteID: The ID of the site that contains the specified comment. + /// - parameters: Additional request parameters. Optional. + /// - success: A closure that will be called when the request succeeds. + /// - failure: A closure that will be called when the request fails. + func getCommentsV2(for siteID: Int, + parameters: [RequestKeys: AnyHashable]? = nil, + success: @escaping ([RemoteCommentV2]) -> Void, + failure: @escaping (Error) -> Void) { + let path = coreV2Path(for: "sites/\(siteID)/comments") + let requestParameters: [String: AnyHashable] = { + guard let someParameters = parameters else { + return [:] + } + + return someParameters.reduce([String: AnyHashable]()) { result, pair in + var result = result + result[pair.key.rawValue] = pair.value + return result + } + }() + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: requestParameters as [String: AnyObject], + type: [RemoteCommentV2].self + ) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(onSuccess: success, onFailure: failure) + } + } + +} + +// MARK: - Private Helpers + +private extension CommentServiceRemoteREST { + struct Constants { + static let coreV2String = "wp/v2" + } + + func coreV2Path(for endpoint: String) -> String { + return "\(Constants.coreV2String)/\(endpoint)" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.h new file mode 100644 index 000000000000..e4323aadce5a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.h @@ -0,0 +1,100 @@ +#import +#import +#import + +@class RemoteUser; +@class RemoteLikeUser; + +@interface CommentServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + Fetch a hierarchical list of comments for the specified post on the specified site. + The comments are returned in the order of nesting, not date. + The request fetches the default number of *parent* comments (20) but may return more + depending on the number of child comments. + + @param postID The ID of the post. + @param page The page number to fetch. + @param number The number to fetch per page. + @param success block called on a successful fetch. Returns the comments array and total comments count. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)syncHierarchicalCommentsForPost:(NSNumber * _Nonnull)postID + page:(NSUInteger)page + number:(NSUInteger)number + success:(void (^ _Nullable)(NSArray * _Nullable comments, NSNumber * _Nonnull found))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Update a comment with a commentID + */ +- (void)updateCommentWithID:(NSNumber * _Nonnull)commentID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Adds a reply to a post with postID + */ +- (void)replyToPostWithID:(NSNumber * _Nonnull)postID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Adds a reply to a comment with commentID. + */ +- (void)replyToCommentWithID:(NSNumber * _Nonnull)commentID + content:(NSString * _Nonnull)content + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Moderate a comment with a commentID + */ +- (void)moderateCommentWithID:(NSNumber * _Nonnull)commentID + status:(NSString * _Nonnull)status + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Trashes a comment with a commentID + */ +- (void)trashCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Like a comment with a commentID + */ +- (void)likeCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + + +/** + Unlike a comment with a commentID + */ +- (void)unlikeCommentWithID:(NSNumber * _Nonnull)commentID + success:(void (^ _Nullable)(void))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + Requests a list of users that liked the comment with the specified ID. Due to + API limitation, up to 90 users will be returned from the endpoint. + + @param commentID The ID for the comment. Cannot be nil. + @param count Number of records to retrieve. Cannot be nil. If 0, will default to endpoint max. + @param before Filter results to Likes before this date/time string. Can be nil. + @param excludeUserIDs Array of user IDs to exclude from response. Can be nil. + @param success The block that will be executed on success. Can be nil. + @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getLikesForCommentID:(NSNumber * _Nonnull)commentID + count:(NSNumber * _Nonnull)count + before:(NSString * _Nullable)before + excludeUserIDs:(NSArray * _Nullable)excludeUserIDs + success:(void (^ _Nullable)(NSArray * _Nonnull users, NSNumber * _Nonnull found))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.m new file mode 100644 index 000000000000..0f13fddd657d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteREST.m @@ -0,0 +1,538 @@ +#import "CommentServiceRemoteREST.h" +#import "WPKit-Swift.h" +#import "RemoteComment.h" +#import "RemoteUser.h" + +@import NSObject_SafeExpectations; +@import WordPressShared; + +@implementation CommentServiceRemoteREST + +#pragma mark Public methods + +#pragma mark - Blog-centric methods + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure +{ + [self getCommentsWithMaximumCount:maximumComments options:nil success:success failure:failure]; +} + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ + @"force": @"wpcom", // Force fetching data from shadow site on Jetpack sites + @"number": @(maximumComments) + }]; + + if (options) { + [parameters addEntriesFromDictionary:options]; + } + + NSNumber *statusFilter = [parameters numberForKey:@"status"]; + [parameters removeObjectForKey:@"status"]; + parameters[@"status"] = [self parameterForCommentStatus:statusFilter]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remoteCommentsFromJSONArray:responseObject[@"comments"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + +} + +- (NSString *)parameterForCommentStatus:(NSNumber *)status +{ + switch (status.intValue) { + case CommentStatusFilterUnapproved: + return @"unapproved"; + break; + case CommentStatusFilterApproved: + return @"approved"; + break; + case CommentStatusFilterTrash: + return @"trash"; + break; + case CommentStatusFilterSpam: + return @"spam"; + break; + default: + return @"all"; + break; + } +} + +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError * error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSString *path; + if (comment.parentID) { + path = [NSString stringWithFormat:@"sites/%@/comments/%@/replies/new", self.siteID, comment.parentID]; + } else { + path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies/new", self.siteID, comment.postID]; + } + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": comment.content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": comment.content, + @"author": comment.author, + @"author_email": comment.authorEmail, + @"author_url": comment.authorUrl, + @"context": @"edit", + }; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"status": [self remoteStatusWithStatus:comment.status], + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromJSONDictionary:responseObject]; + if (success) { + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/delete", self.siteID, comment.commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark Post-centric methods + +- (void)syncHierarchicalCommentsForPost:(NSNumber *)postID + page:(NSUInteger)page + number:(NSUInteger)number + success:(void (^)(NSArray *comments, NSNumber *found))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies?order=ASC&hierarchical=1&page=%lu&number=%lu", self.siteID, postID, (unsigned long)page, (unsigned long)number]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"force": @"wpcom" // Force fetching data from shadow site on Jetpack sites + }; + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *dict = (NSDictionary *)responseObject; + NSArray *comments = [self remoteCommentsFromJSONArray:[dict arrayForKey:@"comments"]]; + NSNumber *found = [responseObject numberForKey:@"found"] ?: @0; + success(comments, found); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark - Public Methods + +- (void)updateCommentWithID:(NSNumber *)commentID + content:(NSString *)content + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)replyToPostWithID:(NSNumber *)postID + content:(NSString *)content + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/replies/new", self.siteID, postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{@"content": content}; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *commentDict = (NSDictionary *)responseObject; + RemoteComment *comment = [self remoteCommentFromJSONDictionary:commentDict]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)replyToCommentWithID:(NSNumber *)commentID + content:(NSString *)content + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/replies/new", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"content": content, + @"context": @"edit", + }; + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary *commentDict = (NSDictionary *)responseObject; + RemoteComment *comment = [self remoteCommentFromJSONDictionary:commentDict]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateCommentWithID:(NSNumber *)commentID + status:(NSString *)status + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ + @"status" : status, + @"context" : @"edit", + }; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/delete", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)likeCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes/new", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unlikeCommentWithID:(NSNumber *)commentID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes/mine/delete", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getLikesForCommentID:(NSNumber *)commentID + count:(NSNumber *)count + before:(NSString *)before + excludeUserIDs:(NSArray *)excludeUserIDs + success:(void (^)(NSArray * _Nonnull users, NSNumber *found))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(commentID); + + NSString *path = [NSString stringWithFormat:@"sites/%@/comments/%@/likes", self.siteID, commentID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + NSNumber *siteID = self.siteID; + + // If no count provided, default to endpoint max. + if (count == 0) { + count = @90; + } + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ @"number": count }]; + + if (before) { + parameters[@"before"] = before; + } + + if (excludeUserIDs) { + parameters[@"exclude"] = excludeUserIDs; + } + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *jsonUsers = responseObject[@"likes"] ?: @[]; + NSArray *users = [self remoteUsersFromJSONArray:jsonUsers commentID:commentID siteID:siteID]; + NSNumber *found = [responseObject numberForKey:@"found"] ?: @0; + success(users, found); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private methods + +- (NSArray *)remoteCommentsFromJSONArray:(NSArray *)jsonComments +{ + return [jsonComments wp_map:^id(NSDictionary *jsonComment) { + return [self remoteCommentFromJSONDictionary:jsonComment]; + }]; +} + +- (RemoteComment *)remoteCommentFromJSONDictionary:(NSDictionary *)jsonDictionary +{ + RemoteComment *comment = [RemoteComment new]; + + comment.authorID = [jsonDictionary numberForKeyPath:@"author.ID"]; + comment.author = jsonDictionary[@"author"][@"name"]; + // Email might be `false`, turn into `nil` + comment.authorEmail = [jsonDictionary[@"author"] stringForKey:@"email"]; + comment.authorUrl = jsonDictionary[@"author"][@"URL"]; + comment.authorAvatarURL = [jsonDictionary stringForKeyPath:@"author.avatar_URL"]; + comment.authorIP = [jsonDictionary stringForKeyPath:@"author.ip_address"]; + comment.commentID = jsonDictionary[@"ID"]; + comment.date = [NSDate dateWithWordPressComJSONString:jsonDictionary[@"date"]]; + comment.link = jsonDictionary[@"URL"]; + comment.parentID = [jsonDictionary numberForKeyPath:@"parent.ID"]; + comment.postID = [jsonDictionary numberForKeyPath:@"post.ID"]; + comment.postTitle = [jsonDictionary stringForKeyPath:@"post.title"]; + comment.status = [self statusWithRemoteStatus:jsonDictionary[@"status"]]; + comment.type = jsonDictionary[@"type"]; + comment.isLiked = [[jsonDictionary numberForKey:@"i_like"] boolValue]; + comment.likeCount = [jsonDictionary numberForKey:@"like_count"]; + comment.canModerate = [[jsonDictionary numberForKey:@"can_moderate"] boolValue]; + comment.content = jsonDictionary[@"content"]; + comment.rawContent = jsonDictionary[@"raw_content"]; + + return comment; +} + +- (NSString *)statusWithRemoteStatus:(NSString *)remoteStatus +{ + NSString *status = remoteStatus; + if ([status isEqualToString:@"unapproved"]) { + status = @"hold"; + } else if ([status isEqualToString:@"approved"]) { + status = @"approve"; + } + return status; +} + +- (NSString *)remoteStatusWithStatus:(NSString *)status +{ + NSString *remoteStatus = status; + if ([remoteStatus isEqualToString:@"hold"]) { + remoteStatus = @"unapproved"; + } else if ([remoteStatus isEqualToString:@"approve"]) { + remoteStatus = @"approved"; + } + return remoteStatus; +} + +/** + Returns an array of RemoteLikeUser based on provided JSON representation of users. + + @param jsonUsers An array containing JSON representations of users. + @param commentID ID of the Comment the users liked. + @param siteID ID of the Comment's site. + */ +- (NSArray *)remoteUsersFromJSONArray:(NSArray *)jsonUsers + commentID:(NSNumber *)commentID + siteID:(NSNumber *)siteID +{ + return [jsonUsers wp_map:^id(NSDictionary *jsonUser) { + return [[RemoteLikeUser alloc] initWithDictionary:jsonUser commentID:commentID siteID:siteID]; + }]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..9c6279b285d4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.h @@ -0,0 +1,7 @@ +#import +#import +#import + +@interface CommentServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..dfe05b5c07e7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/CommentServiceRemoteXMLRPC.m @@ -0,0 +1,232 @@ +#import "CommentServiceRemoteXMLRPC.h" +#import "WPKit-Swift.h" +#import "RemoteComment.h" + +@import wpxmlrpc; +@import WordPressShared; +@import NSObject_SafeExpectations; + +@implementation CommentServiceRemoteXMLRPC + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + success:(void (^)(NSArray *comments))success + failure:(void (^)(NSError *error))failure +{ + [self getCommentsWithMaximumCount:maximumComments options:nil success:success failure:failure]; +} + +- (void)getCommentsWithMaximumCount:(NSInteger)maximumComments + options:(NSDictionary *)options + success:(void (^)(NSArray *posts))success + failure:(void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [@{ @"number": @(maximumComments) } mutableCopy]; + + if (options) { + [extraParameters addEntriesFromDictionary:options]; + } + + NSNumber *statusFilter = [extraParameters numberForKey:@"status"]; + [extraParameters removeObjectForKey:@"status"]; + extraParameters[@"status"] = [self parameterForCommentStatus:statusFilter]; + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + + [self.api callMethod:@"wp.getComments" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([self remoteCommentsFromXMLRPCArray:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSString *)parameterForCommentStatus:(NSNumber *)status +{ + switch (status.intValue) { + case CommentStatusFilterUnapproved: + return @"hold"; + break; + case CommentStatusFilterApproved: + return @"approve"; + break; + case CommentStatusFilterTrash: + return @"trash"; + break; + case CommentStatusFilterSpam: + return @"spam"; + break; + default: + return @"all"; + break; + } +} + +- (void)getCommentWithID:(NSNumber *)commentID + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:commentID]; + [self.api callMethod:@"wp.getComment" + parameters:parameters success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + // TODO: validate response + RemoteComment *comment = [self remoteCommentFromXMLRPCDictionary:responseObject]; + success(comment); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + failure(error); + }]; +} + +- (void)createComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(comment.postID != nil); + NSDictionary *commentDictionary = @{ + @"content": comment.content, + @"comment_parent": comment.parentID, + }; + NSArray *extraParameters = @[ + comment.postID, + commentDictionary, + ]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.newComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSNumber *commentID = responseObject; + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *comment))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(comment.commentID != nil); + NSNumber *commentID = comment.commentID; + + NSDictionary *commentDictionary = @{ + @"content": comment.content, + @"author": comment.author, + @"author_email": comment.authorEmail, + @"author_url": comment.authorUrl, + }; + + NSArray *extraParameters = @[ + comment.commentID, + commentDictionary, + ]; + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + + [self.api callMethod:@"wp.editComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)moderateComment:(RemoteComment *)comment + success:(void (^)(RemoteComment *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(comment.commentID != nil); + NSNumber *commentID = comment.commentID; + NSArray *extraParameters = @[ + commentID, + @{@"status": comment.status}, + ]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.editComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + // TODO: validate response + [self getCommentWithID:commentID + success:success + failure:failure]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + // If the error is a 500 this could be a signal that the error changed status on the server + if ([error.domain isEqualToString:WPXMLRPCFaultErrorDomain] + && error.code == 500) { + if (success) { + success(comment); + } + return; + } + if (failure) { + failure(error); + } + }]; +} + +- (void)trashComment:(RemoteComment *)comment + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(comment.commentID != nil); + NSArray *parameters = [self XMLRPCArgumentsWithExtra:comment.commentID]; + [self.api callMethod:@"wp.deleteComment" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Private methods + +- (NSArray *)remoteCommentsFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wp_map:^id(NSDictionary *xmlrpcComment) { + return [self remoteCommentFromXMLRPCDictionary:xmlrpcComment]; + }]; +} + +- (RemoteComment *)remoteCommentFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemoteComment *comment = [RemoteComment new]; + comment.author = xmlrpcDictionary[@"author"]; + comment.authorEmail = xmlrpcDictionary[@"author_email"]; + comment.authorUrl = xmlrpcDictionary[@"author_url"]; + comment.authorIP = xmlrpcDictionary[@"author_ip"]; + comment.commentID = [xmlrpcDictionary numberForKey:@"comment_id"]; + comment.content = xmlrpcDictionary[@"content"]; + comment.date = xmlrpcDictionary[@"date_created_gmt"]; + comment.link = xmlrpcDictionary[@"link"]; + comment.parentID = [xmlrpcDictionary numberForKey:@"parent"]; + comment.postID = [xmlrpcDictionary numberForKey:@"post_id"]; + comment.postTitle = xmlrpcDictionary[@"post_title"]; + comment.status = xmlrpcDictionary[@"status"]; + comment.type = xmlrpcDictionary[@"type"]; + + return comment; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/DashboardServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/DashboardServiceRemote.swift new file mode 100644 index 000000000000..983ff6f5a80e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/DashboardServiceRemote.swift @@ -0,0 +1,48 @@ +import Foundation + +open class DashboardServiceRemote: ServiceRemoteWordPressComREST { + open func fetch( + cards: [String], + forBlogID blogID: Int, + deviceId: String, + success: @escaping (NSDictionary) -> Void, + failure: @escaping (Error) -> Void + ) { + let requestUrl = self.path(forEndpoint: "sites/\(blogID)/dashboard/cards-data/", withVersion: ._2_0) + var params: [String: AnyObject]? + + do { + params = try self.makeQueryParams(cards: cards, deviceId: deviceId) + } catch { + failure(error) + } + + wordPressComRESTAPI.get(requestUrl, + parameters: params, + success: { response, _ in + guard let cards = response as? NSDictionary else { + failure(ResponseError.decodingFailure) + return + } + + success(cards) + }, failure: { error, _ in + failure(error) + WPKitLogError("Error fetching dashboard cards: \(error)") + }) + } + + private func makeQueryParams(cards: [String], deviceId: String) throws -> [String: AnyObject] { + let cardsParams: [String: AnyObject] = [ + "cards": cards.joined(separator: ",") as NSString + ] + let featureFlagParams: [String: AnyObject]? = try SessionDetails(deviceId: deviceId).dictionaryRepresentation() + return cardsParams.merging(featureFlagParams ?? [:]) { first, second in + return first + } + } + + enum ResponseError: Error { + case decodingFailure + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote+AllDomains.swift b/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote+AllDomains.swift new file mode 100644 index 000000000000..f275784f254a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote+AllDomains.swift @@ -0,0 +1,181 @@ +import Foundation + +extension DomainsServiceRemote { + + // MARK: - API + + /// Makes a call request to `GET /v1.1/all-domains` and returns a list of domain objects. + /// + /// The endpoint accepts 3 **optionals** query params: + /// - `resolve_status` of type `boolean`. If `true`, the response will include a `status` attribute for each `domain` object. + /// - `no_wpcom`of type `boolean`. If `true`, the respnse won't include `wpcom` domains. + /// - `locale` of type `string`. Used for string localization. + public func fetchAllDomains(params: AllDomainsEndpointParams? = nil, completion: @escaping (AllDomainsEndpointResult) -> Void) { + let path = self.path(forEndpoint: "all-domains", withVersion: ._1_1) + let parameters: [String: AnyObject]? + + do { + parameters = try queryParameters(from: params) + } catch let error { + completion(.failure(error)) + return + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + parameters: parameters, + jsonDecoder: decoder, + type: AllDomainsEndpointResponse.self + ) + .map { $0.body.domains } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } + + private func queryParameters(from params: AllDomainsEndpointParams?) throws -> [String: AnyObject]? { + guard let params else { + return nil + } + let encoder = JSONEncoder() + let data = try encoder.encode(params) + let dict = try JSONSerialization.jsonObject(with: data) as? [String: AnyObject] + return dict + } + + // MARK: - Public Types + + public typealias AllDomainsEndpointResult = Result<[AllDomainsListItem], Error> + + public struct AllDomainsEndpointParams { + + public var resolveStatus: Bool = false + public var noWPCOM: Bool = false + public var locale: String? + + public init() {} + } + + public struct AllDomainsListItem { + + public enum StatusType: String { + case success + case premium + case neutral + case warning + case alert + case error + } + + public struct Status { + + public let value: String + public let type: StatusType + + public init(value: String, type: StatusType) { + self.value = value + self.type = type + } + } + + public let domain: String + public let blogId: Int + public let blogName: String + public let type: DomainType + public let isDomainOnlySite: Bool + public let isWpcomStagingDomain: Bool + public let hasRegistration: Bool + public let registrationDate: Date? + public let expiryDate: Date? + public let wpcomDomain: Bool + public let currentUserIsOwner: Bool? + public let siteSlug: String + public let status: Status? + } + + // MARK: - Private Types + + private struct AllDomainsEndpointResponse: Decodable { + let domains: [AllDomainsListItem] + } +} + +// MARK: - Encoding / Decoding + +extension DomainsServiceRemote.AllDomainsEndpointParams: Encodable { + + enum CodingKeys: String, CodingKey { + case resolveStatus = "resolve_status" + case locale + case noWPCOM = "no_wpcom" + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("\(resolveStatus)", forKey: .resolveStatus) + try container.encode("\(noWPCOM)", forKey: .noWPCOM) + try container.encodeIfPresent(locale, forKey: .locale) + } +} + +extension DomainsServiceRemote.AllDomainsListItem.StatusType: Decodable { +} + +extension DomainsServiceRemote.AllDomainsListItem.Status: Decodable { + enum CodingKeys: String, CodingKey { + case value = "status" + case type = "status_type" + } +} + +extension DomainsServiceRemote.AllDomainsListItem: Decodable { + + enum CodingKeys: String, CodingKey { + case domain + case blogId = "blog_id" + case blogName = "blog_name" + case type + case isDomainOnlySite = "is_domain_only_site" + case isWpcomStagingDomain = "is_wpcom_staging_domain" + case hasRegistration = "has_registration" + case registrationDate = "registration_date" + case expiryDate = "expiry" + case wpcomDomain = "wpcom_domain" + case currentUserIsOwner = "current_user_is_owner" + case siteSlug = "site_slug" + case status = "domain_status" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decode(String.self, forKey: .domain) + self.blogId = try container.decode(Int.self, forKey: .blogId) + self.blogName = try container.decode(String.self, forKey: .blogName) + self.isDomainOnlySite = try container.decode(Bool.self, forKey: .isDomainOnlySite) + self.isWpcomStagingDomain = try container.decode(Bool.self, forKey: .isWpcomStagingDomain) + self.hasRegistration = try container.decode(Bool.self, forKey: .hasRegistration) + self.wpcomDomain = try container.decode(Bool.self, forKey: .wpcomDomain) + self.currentUserIsOwner = try container.decode(Bool?.self, forKey: .currentUserIsOwner) + self.siteSlug = try container.decode(String.self, forKey: .siteSlug) + self.registrationDate = try { + if let timestamp = try? container.decodeIfPresent(String.self, forKey: .registrationDate), !timestamp.isEmpty { + return try container.decode(Date.self, forKey: .registrationDate) + } + return nil + }() + self.expiryDate = try { + if let timestamp = try? container.decodeIfPresent(String.self, forKey: .expiryDate), !timestamp.isEmpty { + return try container.decode(Date.self, forKey: .expiryDate) + } + return nil + }() + let type: String = try container.decode(String.self, forKey: .type) + self.type = .init(type: type, wpComDomain: wpcomDomain, hasRegistration: hasRegistration) + self.status = try container.decodeIfPresent(Status.self, forKey: .status) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote.swift new file mode 100644 index 000000000000..51b5738601da --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Domains/DomainsServiceRemote.swift @@ -0,0 +1,321 @@ +import Foundation + +/// Allows the construction of a request for domain suggestions. +/// +public struct DomainSuggestionRequest { + public typealias DomainSuggestionType = DomainsServiceRemote.DomainSuggestionType + + public let query: String + public let segmentID: Int64? + public let quantity: Int? + public let suggestionType: DomainSuggestionType? + + public init(query: String, segmentID: Int64? = nil, quantity: Int? = nil, suggestionType: DomainSuggestionType? = nil) { + self.query = query + self.segmentID = segmentID + self.quantity = quantity + self.suggestionType = suggestionType + } +} + +public struct DomainSuggestion: Codable { + public let domainName: String + public let productID: Int? + public let supportsPrivacy: Bool? + public let costString: String + public let cost: Double? + public let saleCost: Double? + public let isFree: Bool + public let currencyCode: String? + + public var domainNameStrippingSubdomain: String { + return domainName.components(separatedBy: ".").first ?? domainName + } + + public init( + domainName: String, + productID: Int?, + supportsPrivacy: Bool?, + costString: String, + cost: Double? = nil, + saleCost: Double? = nil, + isFree: Bool = false, + currencyCode: String? = nil + ) { + self.domainName = domainName + self.productID = productID + self.supportsPrivacy = supportsPrivacy + self.costString = costString + self.cost = cost + self.saleCost = saleCost + self.isFree = isFree + self.currencyCode = currencyCode + } + + public init(json: [String: AnyObject]) throws { + guard let domain = json["domain_name"] as? String else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + self.domainName = domain + self.productID = json["product_id"] as? Int ?? nil + self.supportsPrivacy = json["supports_privacy"] as? Bool ?? nil + self.costString = json["cost"] as? String ?? "" + self.cost = json["raw_price"] as? Double + self.saleCost = json["sale_cost"] as? Double + self.isFree = json["is_free"] as? Bool ?? false + self.currencyCode = json["currency_code"] as? String + } +} + +public class DomainsServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public enum DomainSuggestionType { + case noWordpressDotCom + case includeWordPressDotCom + case onlyWordPressDotCom + case wordPressDotComAndDotBlogSubdomains + + /// Includes free dotcom sudomains and paid domains. + case freeAndPaid + + case allowlistedTopLevelDomains([String]) + + fileprivate func parameters() -> [String: AnyObject] { + switch self { + case .noWordpressDotCom: + return ["include_wordpressdotcom": false as AnyObject] + case .includeWordPressDotCom: + return ["include_wordpressdotcom": true as AnyObject, + "only_wordpressdotcom": false as AnyObject] + case .onlyWordPressDotCom: + return ["only_wordpressdotcom": true as AnyObject] + case .wordPressDotComAndDotBlogSubdomains: + return ["include_dotblogsubdomain": true as AnyObject, + "vendor": "dot" as AnyObject, + "only_wordpressdotcom": true as AnyObject, + "include_wordpressdotcom": true as AnyObject] + case .freeAndPaid: + return ["include_dotblogsubdomain": false as AnyObject, + "include_wordpressdotcom": true as AnyObject, + "vendor": "mobile" as AnyObject] + case .allowlistedTopLevelDomains(let allowlistedTLDs): + return ["tlds": allowlistedTLDs.joined(separator: ",") as AnyObject] + } + } + } + + public func getDomainsForSite(_ siteID: Int, success: @escaping ([RemoteDomain]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/domains" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, + success: { + response, _ in + do { + try success(mapDomainsResponse(response)) + } catch { + WPKitLogError("Error parsing domains response (\(error)): \(response)") + failure(error) + } + }, failure: { + error, _ in + failure(error) + }) + } + + public func setPrimaryDomainForSite(siteID: Int, domain: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/domains/primary" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters: [String: AnyObject] = ["domain": domain as AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, + success: { _, _ in + + success() + }, failure: { error, _ in + + failure(error) + }) + } + + @objc public func getStates(for countryCode: String, + success: @escaping ([WPState]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "domains/supported-states/\(countryCode)" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get( + servicePath, + parameters: nil, + success: { + response, _ in + do { + guard let json = response as? [AnyObject] else { + throw ResponseError.decodingFailed + } + let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode([WPState].self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing State list for country code (\(error)): \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + public func getDomainContactInformation(success: @escaping (DomainContactInformation) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/domain-contact-information" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get( + servicePath, + parameters: nil, + success: { (response, _) in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode(DomainContactInformation.self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing DomainContactInformation (\(error)): \(response)") + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func validateDomainContactInformation(contactInformation: [String: String], + domainNames: [String], + success: @escaping (ValidateDomainContactInformationResponse) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/domain-contact-information/validate" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + let parameters: [String: AnyObject] = ["contact_information": contactInformation as AnyObject, + "domain_names": domainNames as AnyObject] + wordPressComRESTAPI.post( + servicePath, + parameters: parameters, + success: { response, _ in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode(ValidateDomainContactInformationResponse.self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing ValidateDomainContactInformationResponse (\(error)): \(response)") + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func getDomainSuggestions(request: DomainSuggestionRequest, + success: @escaping ([DomainSuggestion]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "domains/suggestions" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + var parameters: [String: AnyObject] = [ + "query": request.query as AnyObject + ] + + if let suggestionType = request.suggestionType { + parameters.merge(suggestionType.parameters(), uniquingKeysWith: { $1 }) + } + + if let segmentID = request.segmentID { + parameters["segment_id"] = segmentID as AnyObject + } + + if let quantity = request.quantity { + parameters["quantity"] = quantity as AnyObject + } + + wordPressComRESTAPI.get(servicePath, + parameters: parameters, + success: { + response, _ in + do { + let suggestions = try map(suggestions: response) + success(suggestions) + } catch { + WPKitLogError("Error parsing domains response (\(error)): \(response)") + failure(error) + } + }, failure: { + error, _ in + failure(error) + }) + } +} + +private func map(suggestions response: Any) throws -> [DomainSuggestion] { + guard let jsonSuggestions = response as? [[String: AnyObject]] else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + var suggestions: [DomainSuggestion] = [] + for jsonSuggestion in jsonSuggestions { + do { + let suggestion = try DomainSuggestion(json: jsonSuggestion) + suggestions.append(suggestion) + } + } + return suggestions +} + +private func mapDomainsResponse(_ response: Any) throws -> [RemoteDomain] { + guard let json = response as? [String: AnyObject], + let domainsJson = json["domains"] as? [[String: AnyObject]] else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + let domains = try domainsJson.map { domainJson -> RemoteDomain in + + guard let domainName = domainJson["domain"] as? String, + let isPrimary = domainJson["primary_domain"] as? Bool else { + throw DomainsServiceRemote.ResponseError.decodingFailed + } + + let autoRenewing = domainJson["auto_renewing"] as? Bool + let autoRenewalDate = domainJson["auto_renewal_date"] as? String + let expirySoon = domainJson["expiry_soon"] as? Bool + let expired = domainJson["expired"] as? Bool + let expiryDate = domainJson["expiry"] as? String + + return RemoteDomain(domainName: domainName, + isPrimaryDomain: isPrimary, + domainType: domainTypeFromDomainJSON(domainJson), + autoRenewing: autoRenewing, + autoRenewalDate: autoRenewalDate, + expirySoon: expirySoon, + expired: expired, + expiryDate: expiryDate) + } + + return domains +} + +private func domainTypeFromDomainJSON(_ domainJson: [String: AnyObject]) -> DomainType { + if let type = domainJson["type"] as? String, type == "redirect" { + return .siteRedirect + } + + if let wpComDomain = domainJson["wpcom_domain"] as? Bool, wpComDomain == true { + return .wpCom + } + + if let hasRegistration = domainJson["has_registration"] as? Bool, hasRegistration == true { + return .registered + } + + return .mapped +} diff --git a/WordPressKit/Sources/WordPressKit/Services/EditorServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/EditorServiceRemote.swift new file mode 100644 index 000000000000..1d5ba5f8784a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/EditorServiceRemote.swift @@ -0,0 +1,65 @@ +import Foundation +import WordPressShared + +public class EditorServiceRemote: ServiceRemoteWordPressComREST { + public func postDesignateMobileEditor(_ siteID: Int, editor: EditorSettings.Mobile, success: @escaping (EditorSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/gutenberg?platform=mobile&editor=\(editor.rawValue)" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + do { + let settings = try EditorSettings(with: responseObject) + success(settings) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } + + public func postDesignateMobileEditorForAllSites(_ editor: EditorSettings.Mobile, setOnlyIfEmpty: Bool = true, success: @escaping ([Int: EditorSettings.Mobile]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/gutenberg" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let parameters = [ + "platform": "mobile", + "editor": editor.rawValue, + "set_only_if_empty": setOnlyIfEmpty + ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: String] else { + if let boolResponse = responseObject as? Bool, boolResponse == false { + return failure(EditorSettings.Error.badRequest) + } + return failure(EditorSettings.Error.badResponse) + } + + let mappedResponse = response.reduce(into: [Int: EditorSettings.Mobile](), { (result, response) in + if let id = Int(response.key), let editor = EditorSettings.Mobile(rawValue: response.value) { + result[id] = editor + } + }) + success(mappedResponse) + }) { (error, _) in + failure(error) + } + } + + public func getEditorSettings(_ siteID: Int, success: @escaping (EditorSettings) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/gutenberg" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + do { + let settings = try EditorSettings(with: responseObject) + success(settings) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/FeatureFlagRemote.swift b/WordPressKit/Sources/WordPressKit/Services/FeatureFlagRemote.swift new file mode 100644 index 000000000000..205e8b05390a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/FeatureFlagRemote.swift @@ -0,0 +1,57 @@ +import UIKit + +open class FeatureFlagRemote: ServiceRemoteWordPressComREST { + + public typealias FeatureFlagResponseCallback = (Result) -> Void + + public enum FeatureFlagRemoteError: Error { + case InvalidDataError + } + + open func getRemoteFeatureFlags(forDeviceId deviceId: String, callback: @escaping FeatureFlagResponseCallback) { + let params = SessionDetails(deviceId: deviceId) + let endpoint = "mobile/feature-flags" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + var dictionary: [String: AnyObject]? + + do { + dictionary = try params.dictionaryRepresentation() + } catch let error { + callback(.failure(error)) + return + } + + wordPressComRESTAPI.get(path, + parameters: dictionary, + success: { response, _ in + + if let featureFlagList = response as? NSDictionary { + + let reconstitutedList = featureFlagList.compactMap { row -> FeatureFlag? in + guard + let title = row.key as? String, + let value = row.value as? Bool + else { + return nil + } + + return FeatureFlag(title: title, value: value) + }.sorted() + + callback(.success(reconstitutedList)) + } else { + callback(.failure(FeatureFlagRemoteError.InvalidDataError)) + } + + }, failure: { error, response in + WPKitLogError("Error retrieving remote feature flags") + WPKitLogError("\(error)") + + if let response = response { + WPKitLogDebug("Response Code: \(response.statusCode)") + } + + callback(.failure(error)) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/GravatarServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/GravatarServiceRemote.swift new file mode 100644 index 000000000000..2f12cb9e2170 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/GravatarServiceRemote.swift @@ -0,0 +1,158 @@ +import Foundation +import WordPressShared + +/// This ServiceRemote encapsulates all of the interaction with the Gravatar endpoint. +/// +open class GravatarServiceRemote { + let baseGravatarURL = "https://www.gravatar.com/" + + public init() {} + + /// This method fetches the Gravatar profile for the specified email address. + /// + /// - Parameters: + /// - email: The email address of the gravatar profile to fetch. + /// - success: A success block. + /// - failure: A failure block. + /// + open func fetchProfile(_ email: String, success: @escaping ((_ profile: RemoteGravatarProfile) -> Void), failure: @escaping ((_ error: Error?) -> Void)) { + guard let hash = (email as NSString).md5() else { + assertionFailure() + return + } + + fetchProfile(hash: hash, success: success, failure: failure) + } + + /// This method fetches the Gravatar profile for the specified user hash value. + /// + /// - Parameters: + /// - hash: The hash value of the email address of the gravatar profile to fetch. + /// - success: A success block. + /// - failure: A failure block. + /// + open func fetchProfile(hash: String, success: @escaping ((_ profile: RemoteGravatarProfile) -> Void), failure: @escaping ((_ error: Error?) -> Void)) { + let path = baseGravatarURL + hash + ".json" + guard let targetURL = URL(string: path) else { + assertionFailure() + return + } + + let session = URLSession.shared + let task = session.dataTask(with: targetURL) { (data: Data?, _: URLResponse?, error: Error?) in + guard error == nil, let data = data else { + failure(error) + return + } + do { + let jsonData = try JSONSerialization.jsonObject(with: data, options: .allowFragments) + + guard let jsonDictionary = jsonData as? [String: [Any]], + let entry = jsonDictionary["entry"], + let profileData = entry.first as? NSDictionary else { + DispatchQueue.main.async { + // This case typically happens when the endpoint does + // successfully return but doesn't find the user. + failure(nil) + } + return + } + + let profile = RemoteGravatarProfile(dictionary: profileData) + DispatchQueue.main.async { + success(profile) + } + return + + } catch { + failure(error) + return + } + } + + task.resume() + } + + /// This method hits the Gravatar Endpoint, and uploads a new image, to be used as profile. + /// + /// - Parameters: + /// - image: The new Gravatar Image, to be uploaded + /// - completion: An optional closure to be executed on completion. + /// + open func uploadImage(_ image: UIImage, accountEmail: String, accountToken: String, completion: ((_ error: NSError?) -> Void)?) { + guard let targetURL = URL(string: UploadParameters.endpointURL) else { + assertionFailure() + return + } + + // Boundary + let boundary = boundaryForRequest() + + // Request + let request = NSMutableURLRequest(url: targetURL) + request.httpMethod = UploadParameters.HTTPMethod + request.setValue("Bearer \(accountToken)", forHTTPHeaderField: "Authorization") + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + // Body + let gravatarData = image.pngData()! + let requestBody = bodyWithGravatarData(gravatarData, account: accountEmail, boundary: boundary) + + // Task + let session = URLSession.shared + let task = session.uploadTask(with: request as URLRequest, from: requestBody, completionHandler: { (_, _, error) in + completion?(error as NSError?) + }) + + task.resume() + } + + // MARK: - Private Helpers + + /// Returns a new (randomized) Boundary String + /// + private func boundaryForRequest() -> String { + return "Boundary-" + UUID().uuidString + } + + /// Returns the Body for a Gravatar Upload OP. + /// + /// - Parameters: + /// - gravatarData: The NSData-Encoded Image + /// - account: The account that will get updated + /// - boundary: The request's Boundary String + /// + /// - Returns: A NSData instance, containing the Request's Payload. + /// + private func bodyWithGravatarData(_ gravatarData: Data, account: String, boundary: String) -> Data { + let body = NSMutableData() + + // Image Payload + body.appendString("--\(boundary)\r\n") + body.appendString("Content-Disposition: form-data; name=\(UploadParameters.imageKey); ") + body.appendString("filename=\(UploadParameters.filename)\r\n") + body.appendString("Content-Type: \(UploadParameters.contentType);\r\n\r\n") + body.append(gravatarData) + body.appendString("\r\n") + + // Account Payload + body.appendString("--\(boundary)\r\n") + body.appendString("Content-Disposition: form-data; name=\"\(UploadParameters.accountKey)\"\r\n\r\n") + body.appendString("\(account)\r\n") + + // EOF! + body.appendString("--\(boundary)--\r\n") + + return body as Data + } + + // MARK: - Private Structs + private struct UploadParameters { + static let endpointURL = "https://api.gravatar.com/v1/upload-image" + static let HTTPMethod = "POST" + static let contentType = "application/octet-stream" + static let filename = "profile.png" + static let imageKey = "filedata" + static let accountKey = "account" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/HomepageSettingsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/HomepageSettingsServiceRemote.swift new file mode 100644 index 000000000000..5c22783ff2c2 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/HomepageSettingsServiceRemote.swift @@ -0,0 +1,42 @@ +import Foundation + +public class HomepageSettingsServiceRemote: ServiceRemoteWordPressComREST { + + /** + Sets the homepage type for the specified site. + - Parameters: + - type: The type of homepage to use: blog posts (.posts), or static pages (.page). + - siteID: The ID of the site to update + - postsPageID: The ID of the page to use as the blog page if the homepage type is .page + - homePageID: The ID of the page to use as the homepage is the homepage type is .pag + - success: Completion block called after the settings have been successfully updated + - failure: Failure block called if settings were not successfully updated + */ + public func setHomepageType(type: RemoteHomepageType, for siteID: Int, withPostsPageID postsPageID: Int? = nil, homePageID: Int? = nil, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/homepage" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [Keys.isPageOnFront: type.isPageOnFront as AnyObject] + + if let homePageID = homePageID { + parameters[Keys.pageOnFrontID] = homePageID as AnyObject + } + + if let postsPageID = postsPageID { + parameters[Keys.pageForPostsID] = postsPageID as AnyObject + } + + wordPressComRESTAPI.post(path, parameters: parameters, + success: { _, _ in + success() + }, failure: { error, _ in + failure(error) + }) + } + + private enum Keys { + static let isPageOnFront = "is_page_on_front" + static let pageOnFrontID = "page_on_front_id" + static let pageForPostsID = "page_for_posts_id" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/IPLocationRemote.swift b/WordPressKit/Sources/WordPressKit/Services/IPLocationRemote.swift new file mode 100644 index 000000000000..ad65ee6eaeed --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/IPLocationRemote.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Remote type to fetch the user's IP Location using the public `geo` API. +/// +public final class IPLocationRemote { + private enum Constants { + static let jsonDecoder = JSONDecoder() + } + + private let urlSession: URLSession + + public init(urlSession: URLSession = URLSession.shared) { + self.urlSession = urlSession + } + + /// Fetches the country code from the device ip. + /// + public func fetchIPCountryCode(completion: @escaping (Result) -> Void) { + let url = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseURL.appendingPathComponent("geo/") + + let request = URLRequest(url: url) + let task = urlSession.dataTask(with: request) { data, _, error in + guard let data else { + completion(.failure(IPLocationError.requestFailure(error))) + return + } + + do { + let result = try Constants.jsonDecoder.decode(RemoteIPCountryCode.self, from: data) + completion(.success(result.countryCode)) + } catch { + completion(.failure(error)) + } + } + task.resume() + } +} + +public extension IPLocationRemote { + enum IPLocationError: Error { + case requestFailure(Error?) + } +} + +public struct RemoteIPCountryCode: Decodable { + enum CodingKeys: String, CodingKey { + case countryCode = "country_short" + } + + let countryCode: String +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JSONDecoderExtension.swift b/WordPressKit/Sources/WordPressKit/Services/JSONDecoderExtension.swift new file mode 100644 index 000000000000..050dcbce7522 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JSONDecoderExtension.swift @@ -0,0 +1,52 @@ +import Foundation + +extension JSONDecoder { + + static var apiDecoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.supportMultipleDateFormats + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } +} + +extension JSONDecoder.DateDecodingStrategy { + + enum DateFormat: String, CaseIterable { + case noTime = "yyyy-mm-dd" + case dateWithTime = "yyyy-MM-dd HH:mm:ss" + case iso8601 = "yyyy-MM-dd'T'HH:mm:ssZ" + case iso8601WithMilliseconds = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" + + var formatter: DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = rawValue + return dateFormatter + } + } + + static var supportMultipleDateFormats: JSONDecoder.DateDecodingStrategy { + return JSONDecoder.DateDecodingStrategy.custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateStr = try container.decode(String.self) + + var date: Date? + + for format in DateFormat.allCases { + date = format.formatter.date(from: dateStr) + if date != nil { + break + } + } + + guard let calculatedDate = date else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Cannot decode date string \(dateStr)" + ) + } + + return calculatedDate + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackAIServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackAIServiceRemote.swift new file mode 100644 index 000000000000..501cfcf0fb4d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackAIServiceRemote.swift @@ -0,0 +1,86 @@ +import Foundation + +public final class JetpackAIServiceRemote: SiteServiceRemoteWordPressComREST { + + /// Returns information about your current tier, requests limit, and more. + public func getAssistantFeatureDetails() async throws -> JetpackAssistantFeatureDetails { + let path = path(forEndpoint: "sites/\(siteID)/jetpack-ai/ai-assistant-feature", withVersion: ._2_0) + let response = await wordPressComRestApi.perform(.get, URLString: path, type: JetpackAssistantFeatureDetails.self) + return try response.get().body + } + + /// Returns short-lived JWT token (lifetime is in minutes). + public func getAuthorizationToken() async throws -> String { + struct Response: Decodable { + let token: String + } + let path = path(forEndpoint: "sites/\(siteID)/jetpack-openai-query/jwt", withVersion: ._2_0) + let response = await wordPressComRestApi.perform(.post, URLString: path, type: Response.self) + return try response.get().body.token + } + + /// - parameter token: Token retrieved using ``JetpackAIServiceRemote/getAuthorizationToken``. + public func transcribeAudio(from fileURL: URL, token: String) async throws -> String { + let path = path(forEndpoint: "jetpack-ai-transcription?feature=voice-to-content", withVersion: ._2_0) + let file = FilePart(parameterName: "audio_file", url: fileURL, fileName: "voice_recording", mimeType: "audio/m4a") + let result = await wordPressComRestApi.upload(URLString: path, httpHeaders: [ + "Authorization": "Bearer \(token)" + ], fileParts: [file]) + guard let body = try result.get().body as? [String: Any], + let text = body["text"] as? String else { + throw URLError(.unknown) + } + return text + } + + /// - parameter token: Token retrieved using ``JetpackAIServiceRemote/getAuthorizationToken``. + public func makePostContent(fromPlainText plainText: String, token: String) async throws -> String { + let path = path(forEndpoint: "jetpack-ai-query", withVersion: ._2_0) + let request = JetpackAIQueryRequest(messages: [ + .init(role: "jetpack-ai", context: .init(type: "voice-to-content-simple-draft", content: plainText)) + ], feature: "voice-to-content", stream: false) + let builder = try wordPressComRestApi.requestBuilder(URLString: path) + .method(.post) + .headers(["Authorization": "Bearer \(token)"]) + .body(json: request, jsonEncoder: JSONEncoder()) + let result = await wordPressComRestApi.perform(request: builder) { data in + try JSONDecoder().decode(JetpackAIQueryResponse.self, from: data) + } + let response = try result.get().body + guard let content = response.choices.first?.message.content else { + throw URLError(.unknown) + } + return content + } +} + +private struct JetpackAIQueryRequest: Encodable { + let messages: [Message] + let feature: String + let stream: Bool + + struct Message: Encodable { + let role: String + let context: Context + } + + struct Context: Codable { + let type: String + let content: String + } +} + +private struct JetpackAIQueryResponse: Decodable { + let model: String? + let choices: [Choice] + + struct Choice: Codable { + let index: Int + let message: Message + } + + struct Message: Codable { + let role: String? + let content: String + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackBackupServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackBackupServiceRemote.swift new file mode 100644 index 000000000000..ce4429156bfd --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackBackupServiceRemote.swift @@ -0,0 +1,136 @@ +import Foundation +import WordPressShared + +open class JetpackBackupServiceRemote: ServiceRemoteWordPressComREST { + + /// Prepare a downloadable backup snapshot for a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - rewindID: The rewindID of the snapshot to download. + /// - types: The types of items to restore. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func prepareBackup(_ siteID: Int, + rewindID: String? = nil, + types: JetpackRestoreTypes? = nil, + success: @escaping (_ backup: JetpackBackup) -> Void, + failure: @escaping (Error) -> Void) { + let path = backupPath(for: siteID) + var parameters: [String: AnyObject] = [:] + + if let rewindID = rewindID { + parameters["rewindId"] = rewindID as AnyObject + } + if let types = types { + parameters["types"] = types.toDictionary() as AnyObject + } + + wordPressComRESTAPI.post(path, parameters: parameters, success: { response, _ in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackBackup.self, from: data) + success(envelope) + } catch { + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Get the backup download status for a site and downloadID. + /// - Parameters: + /// - siteID: The target site's ID. + /// - downloadID: The download ID of the snapshot being downloaded. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func getBackupStatus(_ siteID: Int, + downloadID: Int, + success: @escaping (_ backup: JetpackBackup) -> Void, + failure: @escaping (Error) -> Void) { + getDownloadStatus(siteID, downloadID: downloadID, success: success, failure: failure) + } + + /// Get the backup status for all the backups in a site. + /// - Parameters: + /// - siteID: The target site's ID. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: A backup snapshot object. + /// + open func getAllBackupStatus(_ siteID: Int, + success: @escaping (_ backup: [JetpackBackup]) -> Void, + failure: @escaping (Error) -> Void) { + getDownloadStatus(siteID, success: success, failure: failure) + } + + /// Mark a backup as dismissed + /// - Parameters: + /// - siteID: The target site's ID. + /// - downloadID: The download ID of the snapshot being downloaded. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + open func markAsDismissed(_ siteID: Int, + downloadID: Int, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = backupPath(for: siteID, with: "\(downloadID)") + + let parameters = ["dismissed": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { _, _ in + success() + }, failure: { error, _ in + failure(error) + }) + } + + // MARK: - Private + + private func getDownloadStatus(_ siteID: Int, + downloadID: Int? = nil, + success: @escaping (_ backup: T) -> Void, + failure: @escaping (Error) -> Void) { + + let path: String + if let downloadID = downloadID { + path = backupPath(for: siteID, with: "\(downloadID)") + } else { + path = backupPath(for: siteID) + } + + wordPressComRESTAPI.get(path, parameters: nil, success: { response, _ in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(T.self, from: data) + success(envelope) + } catch { + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + private func backupPath(for siteID: Int, with path: String? = nil) -> String { + var endpoint = "sites/\(siteID)/rewind/downloads/" + + if let path = path { + endpoint = endpoint.appending(path) + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackCapabilitiesServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackCapabilitiesServiceRemote.swift new file mode 100644 index 000000000000..aad2c331cd5b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackCapabilitiesServiceRemote.swift @@ -0,0 +1,41 @@ +import Foundation + +/// A service that returns the Jetpack Capabilities for a set of blogs +open class JetpackCapabilitiesServiceRemote: ServiceRemoteWordPressComREST { + + /// Returns a Dictionary of capabilities for each given siteID + /// - Parameters: + /// - siteIds: an array of Int representing siteIDs + /// - success: a success block that accepts a dictionary as a parameter + open func `for`(siteIds: [Int], success: @escaping ([String: AnyObject]) -> Void) { + var jetpackCapabilities: [String: AnyObject] = [:] + let dispatchGroup = DispatchGroup() + let dispatchQueue = DispatchQueue(label: "com.rewind.capabilities") + + siteIds.forEach { siteID in + dispatchGroup.enter() + + dispatchQueue.async { + let endpoint = "sites/\(siteID)/rewind/capabilities" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + self.wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + if let capabilities = (response as? [String: AnyObject])?["capabilities"] as? [String] { + jetpackCapabilities["\(siteID)"] = capabilities as AnyObject + } + + dispatchGroup.leave() + }, failure: { _, _ in + dispatchGroup.leave() + }) + } + } + + dispatchGroup.notify(queue: .global(qos: .background)) { + success(jetpackCapabilities) + } + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackProxyServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackProxyServiceRemote.swift new file mode 100644 index 000000000000..1a8c57ca70be --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackProxyServiceRemote.swift @@ -0,0 +1,58 @@ +/// Encapsulates Jetpack Proxy requests. +public class JetpackProxyServiceRemote: ServiceRemoteWordPressComREST { + + /// Represents the most common HTTP methods for the proxied request. + public enum DotComMethod: String { + case get + case post + case put + case delete + } + + /// Sends a proxied request to a Jetpack-connected site through the Jetpack Proxy API. + /// The proxy API expects the client to be authenticated with a WordPress.com account. + /// + /// - Parameters: + /// - siteID: The dotcom ID of the Jetpack-connected site. + /// - path: The request endpoint to be proxied. + /// - method: The HTTP method for the proxied request. + /// - parameters: The request parameter for the proxied request. Defaults to empty. + /// - locale: The user locale, if any. Defaults to nil. + /// - completion: Closure called after the request completes. + /// - Returns: A Progress object, which can be used to cancel the request if needed. + @discardableResult + public func proxyRequest(for siteID: Int, + path: String, + method: DotComMethod, + parameters: [String: AnyHashable] = [:], + locale: String? = nil, + completion: @escaping (Result) -> Void) -> Progress? { + let urlString = self.path(forEndpoint: "jetpack-blogs/\(siteID)/rest-api", withVersion: ._1_1) + + // Construct the request parameters to be forwarded to the actual endpoint. + var requestParams: [String: AnyHashable] = [ + "json": "true", + "path": "\(path)&_method=\(method.rawValue)" + ] + + // The parameters need to be encoded into a JSON string. + if !parameters.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: parameters, options: []), + let jsonString = String(data: data, encoding: .utf8) { + // Use "query" for the body parameters if the method is GET. Otherwise, always use "body". + let bodyParameterKey = (method == .get ? "query" : "body") + requestParams[bodyParameterKey] = jsonString + } + + if let locale, + !locale.isEmpty { + requestParams["locale"] = locale + } + + return wordPressComRESTAPI.post(urlString, parameters: requestParams) { response, _ in + completion(.success(response)) + } failure: { error, _ in + completion(.failure(error)) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackScanServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackScanServiceRemote.swift new file mode 100644 index 000000000000..0682d7b3ce2f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackScanServiceRemote.swift @@ -0,0 +1,161 @@ +import Foundation +import WordPressShared + +public class JetpackScanServiceRemote: ServiceRemoteWordPressComREST { + // MARK: - Scanning + public func getScanAvailableForSite(_ siteID: Int, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { (scan) in + success(scan.isEnabled) + }, failure: failure) + } + + public func getCurrentScanStatusForSite(_ siteID: Int, success: @escaping(JetpackScanStatus?) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { scan in + success(scan.current) + }, failure: failure) + } + + /// Starts a scan for a site + public func startScanForSite(_ siteID: Int, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + let path = self.scanPath(for: siteID, with: "enqueue") + + wordPressComRESTAPI.post(path, parameters: nil, success: { (response, _) in + guard let responseDict = response as? [String: Any], + let responseValue = responseDict["success"] as? Bool else { + success(false) + return + } + + success(responseValue) + }, failure: { (error, _) in + failure(error) + }) + } + + /// Gets the main scan object + public func getScanForSite(_ siteID: Int, success: @escaping(JetpackScan) -> Void, failure: @escaping(Error) -> Void) { + let path = self.scanPath(for: siteID) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackScan.self, from: data) + + success(envelope) + } catch { + failure(error) + } + + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - Threats + public enum ThreatError: Swift.Error { + case invalidResponse + } + + public func getThreatsForSite(_ siteID: Int, success: @escaping([JetpackScanThreat]?) -> Void, failure: @escaping(Error) -> Void) { + getScanForSite(siteID, success: { scan in + success(scan.threats) + }, failure: failure) + } + + /// Begins the fix process for multiple threats + public func fixThreats(_ threats: [JetpackScanThreat], siteID: Int, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/fix", withVersion: ._2_0) + let parameters = ["threat_ids": threats.map { $0.id as AnyObject }] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackThreatFixResponse.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + /// Begins the fix process for a single threat + public func fixThreat(_ threat: JetpackScanThreat, siteID: Int, success: @escaping(JetpackThreatFixStatus) -> Void, failure: @escaping(Error) -> Void) { + fixThreats([threat], siteID: siteID, success: { response in + guard let status = response.threats.first else { + failure(ThreatError.invalidResponse) + return + } + + success(status) + }, failure: { error in + failure(error) + }) + } + + /// Begins the ignore process for a single threat + public func ignoreThreat(_ threat: JetpackScanThreat, siteID: Int, success: @escaping () -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/\(threat.id)", withVersion: ._2_0) + let parameters = ["ignore": true] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (_, _) in + success() + }, failure: { (error, _) in + failure(error) + }) + } + + /// Returns the fix status for multiple threats + public func getFixStatusForThreats(_ threats: [JetpackScanThreat], siteID: Int, success: @escaping(JetpackThreatFixResponse) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/alerts/fix", withVersion: ._2_0) + let parameters = ["threat_ids": threats.map { $0.id as AnyObject }] as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackThreatFixResponse.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - History + public func getHistoryForSite(_ siteID: Int, success: @escaping(JetpackScanHistory) -> Void, failure: @escaping(Error) -> Void) { + let path = scanPath(for: siteID, with: "history") + + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(JetpackScanHistory.self, from: data) + + success(envelope) + } catch { + failure(error) + } + }, failure: { (error, _) in + failure(error) + }) + } + + // MARK: - Private + private func scanPath(for siteID: Int, with path: String? = nil) -> String { + var endpoint = "sites/\(siteID)/scan/" + + if let path = path { + endpoint = endpoint.appending(path) + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackServiceRemote.swift new file mode 100644 index 000000000000..b84d62f78c2c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackServiceRemote.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct JetpackInstallError: LocalizedError, Equatable { + public enum ErrorType: String { + case invalidCredentials = "INVALID_CREDENTIALS" + case forbidden = "FORBIDDEN" + case installFailure = "INSTALL_FAILURE" + case installResponseError = "INSTALL_RESPONSE_ERROR" + case loginFailure = "LOGIN_FAILURE" + case siteIsJetpack = "SITE_IS_JETPACK" + case activationOnInstallFailure = "ACTIVATION_ON_INSTALL_FAILURE" + case activationResponseError = "ACTIVATION_RESPONSE_ERROR" + case activationFailure = "ACTIVATION_FAILURE" + case unknown + + init(error key: String) { + self = ErrorType(rawValue: key) ?? .unknown + } + } + + public var title: String? + public var code: Int + public var type: ErrorType + + public static var unknown: JetpackInstallError { + return JetpackInstallError(type: .unknown) + } + + public init(title: String? = nil, code: Int = 0, key: String? = nil) { + self.init(title: title, code: code, type: ErrorType(error: key ?? "")) + } + + public init(title: String? = nil, code: Int = 0, type: ErrorType = .unknown) { + self.title = title + self.code = code + self.type = type + } +} + +public class JetpackServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public func checkSiteHasJetpack(_ url: URL, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "connect/site-info", withVersion: ._1_0) + let parameters = ["url": url.absoluteString as AnyObject] + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { [weak self] response, _ in + do { + let hasJetpack = try self?.hasJetpackMapping(object: response) + success(hasJetpack ?? false) + } catch { + failure(error) + } + }) { error, _ in + failure(error) + } + } + + public func installJetpack(url: String, + username: String, + password: String, + completion: @escaping (Bool, JetpackInstallError?) -> Void) { + guard let escapedURL = url.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) else { + completion(false, .unknown) + return + } + let path = String(format: "jetpack-install/%@/", escapedURL) + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_0) + let parameters = ["user": username, + "password": password] + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters as [String: AnyObject], + success: { response, _ in + if let response = response as? [String: Bool], + let success = response[Constants.status] { + completion(success, nil) + } else { + completion(false, JetpackInstallError(type: .installResponseError)) + } + }) { error, _ in + let error = error as NSError + let key = error.userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String + let jetpackError = JetpackInstallError(title: error.localizedDescription, + code: error.code, + key: key) + completion(false, jetpackError) + } + } + + private enum Constants { + static let hasJetpack = "hasJetpack" + static let status = "status" + } +} + +private extension JetpackServiceRemote { + func hasJetpackMapping(object: Any) throws -> Bool { + guard let response = object as? [String: AnyObject], + let hasJetpack = response[Constants.hasJetpack] as? NSNumber else { + throw ResponseError.decodingFailed + } + return hasJetpack.boolValue + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/JetpackSocialServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/JetpackSocialServiceRemote.swift new file mode 100644 index 000000000000..aa3fb53bf97b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/JetpackSocialServiceRemote.swift @@ -0,0 +1,32 @@ +/// Encapsulates remote service logic related to Jetpack Social. +public class JetpackSocialServiceRemote: ServiceRemoteWordPressComREST { + + /// Retrieves the Publicize information for the given site. + /// + /// Note: Sites with disabled share limits will return success with nil value. + /// + /// - Parameters: + /// - siteID: The target site's dotcom ID. + /// - completion: Closure to be called once the request completes. + public func fetchPublicizeInfo(for siteID: Int, + completion: @escaping (Result) -> Void) { + let path = path(forEndpoint: "sites/\(siteID)/jetpack-social", withVersion: ._2_0) + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: path, + jsonDecoder: .apiDecoder, + type: RemotePublicizeInfo.self + ) + .map { $0.body } + .flatMapError { original -> Result in + if case let .endpointError(endpointError) = original, endpointError.response?.statusCode == 200, endpointError.code == .responseSerializationFailed { + return .success(nil) + } + return .failure(original.asNSError()) + } + .execute(completion) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemote.h new file mode 100644 index 000000000000..30be85ea4908 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemote.h @@ -0,0 +1,97 @@ +#import + +@class RemoteMedia; +@class RemoteVideoPressVideo; + +@protocol MediaServiceRemote + + +- (void)getMediaWithID:(NSNumber *)mediaID + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +/** + * Update media details on the server + * + * @param media the media object to update + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be executed when the request fails. + */ +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure; + +/** + * Delete media from the server. Note the media is deleted, not trashed. + * + * @param media the media object to delete + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be executed when the request fails. + */ +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * Get all WordPress Media Library items in batches. + * + * The `pageLoad` block is called with media items in each page, except the last page. If there is only one page of media + * items, the `pageLoad` block will not be called. + * + * The `success` block is called with all media items in the Media Library. Calling this block marks the end of the loading. + * + * The `failure` block is called when any API call fails. Calling this block marks the end of the loading. + * + * @param pageLoad a block to be executed when each page of media is loaded. + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be execute when the request fails. + */ +- (void)getMediaLibraryWithPageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure; + +/** + * Get the number of media items available in the blog + * + * @param mediaType the type of media to count for (image, video, audio, application) + * @param success a block to be executed when the request finishes with success. + * @param failure a block to be execute when the request fails. + */ +- (void)getMediaLibraryCountForType:(NSString *)mediaType + withSuccess:(void (^)(NSInteger))success + failure:(void (^)(NSError *))failure; + +/** + * Retrieves the metadata of a VideoPress video. + * + * The metadata parameters can be found in the API reference: + * https://developer.wordpress.com/docs/api/1.1/get/videos/%24guid/ + * + * @param videoPressID ID of the video in VideoPress. + * @param isSitePrivate true if the site is private, this will be used to determine the fetch of the VideoPress token. + * @param success a block to be executed when the metadata is fetched successfully. + * @param failure a block to be executed when the metadata can't be fetched. + */ +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)isSitePrivate + success:(void (^)(RemoteVideoPressVideo *metadata))success + failure:(void (^)(NSError *))failure; + +/** + Retrieves the VideoPress token for the request videoPressID. + The token is required to play private VideoPress videos. + + @param videoPressID the videoPressID to search for. + @param success a block to be executed if the the token is fetched successfully for the VideoPress video. + @param failure a block to be executed if the token can't be fetched for the VideoPress video. + */ +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.h new file mode 100644 index 000000000000..b573ed97f2dd --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.h @@ -0,0 +1,40 @@ +#import +#import +#import + +@interface MediaServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + Populates a RemoteMedia instance using values from a json dict returned + from the endpoint. + + @param jsonMedia Media dictionary returned from the remote endpoint + @return A RemoteMedia instance + */ ++ (RemoteMedia *)remoteMediaFromJSONDictionary:(NSDictionary *)jsonMedia; + + +/** + Populates a array of RemoteMedia instances using an array of json dicts returned + from the endpoint. + + @param jsonMedia An array of media dicts returned from the remote endpoint + @return A array of RemoteMedia instances + */ ++ (NSArray *)remoteMediaFromJSONArray:(NSArray *)jsonMedia; + +/** + * @brief Upload multiple media items to the remote site. + * + * @discussion This purpose of this method is to give app extensions the ability to upload media via background sessions. + * + * @param mediaItems The media items to create remotely. + * @param requestEnqueued The block that will be executed when the network request is queued. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)uploadMedia:(NSArray *)mediaItems + requestEnqueued:(void (^)(NSNumber *taskID))requestEnqueued + success:(void (^)(NSArray *remoteMedia))success + failure:(void (^)(NSError *error))failure; +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.m new file mode 100644 index 000000000000..5c50b0854f5e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteREST.m @@ -0,0 +1,460 @@ +#import "MediaServiceRemoteREST.h" +#import "RemoteMedia.h" +#import "WPKit-Swift.h" +@import WordPressShared; +@import NSObject_SafeExpectations; + +const NSInteger WPRestErrorCodeMediaNew = 10; + +@implementation MediaServiceRemoteREST + +- (void)getMediaWithID:(NSNumber *)mediaID + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSString *apiPath = [NSString stringWithFormat:@"sites/%@/media/%@", self.siteID, mediaID]; + NSString *requestUrl = [self pathForEndpoint:apiPath + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary * parameters = @{}; + + [self.wordPressComRESTAPI get:requestUrl parameters:parameters success:^(id responseObject, NSHTTPURLResponse *response) { + if (success) { + NSDictionary *response = (NSDictionary *)responseObject; + success([MediaServiceRemoteREST remoteMediaFromJSONDictionary:response]); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryWithPageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSMutableArray *media = [NSMutableArray array]; + NSString *path = [NSString stringWithFormat:@"sites/%@/media", self.siteID]; + [self getMediaLibraryPage:nil + media:media + path:path + pageLoad:pageLoad + success:success + failure:failure]; +} + +- (void)getMediaLibraryPage:(NSString *)pageHandle + media:(NSMutableArray *)media + path:(NSString *)path + pageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[@"number"] = @100; + if ([pageHandle length]) { + parameters[@"page_handle"] = pageHandle; + } + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:[NSDictionary dictionaryWithDictionary:parameters] + success:^(id responseObject, NSHTTPURLResponse *response) { + NSArray *mediaItems = responseObject[@"media"]; + NSArray *pageItems = [MediaServiceRemoteREST remoteMediaFromJSONArray:mediaItems]; + [media addObjectsFromArray:pageItems]; + NSDictionary *meta = responseObject[@"meta"]; + NSString *nextPage = meta[@"next_page"]; + if (nextPage.length) { + if (pageItems.count) { + if(pageLoad) { + pageLoad(pageItems); + } + } + [self getMediaLibraryPage:nextPage + media:media + path:path + pageLoad:pageLoad + success:success + failure:failure]; + } else if (success) { + success([NSArray arrayWithArray:media]); + } + } + failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryCountForType:(NSString *)mediaType + withSuccess:(void (^)(NSInteger))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/media", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ @"number" : @1 }]; + if (mediaType) { + parameters[@"mime_type"] = mediaType; + } + + [self.wordPressComRESTAPI get:requestUrl + parameters:[NSDictionary dictionaryWithDictionary:parameters] + success:^(id responseObject, NSHTTPURLResponse *response) { + NSDictionary *jsonDictionary = (NSDictionary *)responseObject; + NSNumber *count = [jsonDictionary numberForKey:@"found"]; + if (success) { + success([count intValue]); + } + } + failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)uploadMedia:(NSArray *)mediaItems + requestEnqueued:(void (^)(NSNumber *taskID))requestEnqueued + success:(void (^)(NSArray *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert(mediaItems); + + NSString *apiPath = [NSString stringWithFormat:@"sites/%@/media/new", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:apiPath + withVersion:WordPressComRESTAPIVersion_1_1]; + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{}]; + NSMutableArray *fileParts = [NSMutableArray array]; + + for (RemoteMedia *remoteMedia in mediaItems) { + NSString *type = remoteMedia.mimeType; + NSString *filename = remoteMedia.file; + if (remoteMedia.postID != nil && [remoteMedia.postID compare:@(0)] == NSOrderedDescending) { + parameters[@"attrs[0][parent_id]"] = remoteMedia.postID; + } + FilePart *filePart = [[FilePart alloc] initWithParameterName:@"media[]" url:remoteMedia.localURL fileName:filename mimeType:type]; + [fileParts addObject:filePart]; + } + + [self.wordPressComRESTAPI multipartPOST:requestUrl + parameters:parameters + fileParts:fileParts + requestEnqueued:^(NSNumber *taskID) { + if (requestEnqueued) { + requestEnqueued(taskID); + } + } success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSArray *errorList = response[@"errors"]; + NSArray *mediaList = response[@"media"]; + NSMutableArray *returnedRemoteMedia = [NSMutableArray array]; + + if (mediaList.count > 0) { + for (NSDictionary *returnedMediaDict in mediaList) { + RemoteMedia *remoteMedia = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:returnedMediaDict]; + [returnedRemoteMedia addObject:remoteMedia]; + } + + if (success) { + success(returnedRemoteMedia); + } + } else { + NSError *error = [self processMediaUploadErrors:errorList]; + if (failure) { + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + WPKitLogDebug(@"Error uploading multiple media files: %@", [error localizedDescription]); + if (failure) { + failure(error); + } + }]; + +} + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSString *type = media.mimeType; + NSString *filename = media.file; + + NSString *apiPath = [NSString stringWithFormat:@"sites/%@/media/new", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:apiPath + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersForUploadMedia:media]; + + if (media.localURL == nil || filename == nil || type == nil) { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorFileDoesNotExist + userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Media doesn't have an associated file to upload.", @"Error message to show to users when trying to upload a media object with no local file associated")}]; + failure(error); + } + return; + } + FilePart *filePart = [[FilePart alloc] initWithParameterName:@"media[]" url:media.localURL fileName:filename mimeType:type]; + __block NSProgress *localProgress = [self.wordPressComRESTAPI multipartPOST:requestUrl + parameters:parameters + fileParts:@[filePart] + requestEnqueued:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSArray *errorList = response[@"errors"]; + NSArray *mediaList = response[@"media"]; + if (mediaList.count > 0){ + RemoteMedia *remoteMedia = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:mediaList[0]]; + if (success) { + success(remoteMedia); + } + } else { + NSError *error = [self processMediaUploadErrors:errorList]; + if (failure) { + failure(error); + } + } + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + WPKitLogDebug(@"Error uploading file: %@", [error localizedDescription]); + if (failure) { + failure(error); + } + }]; + + *progress = localProgress; +} + +- (NSError *)processMediaUploadErrors:(NSArray *)errorList { + WPKitLogDebug(@"Error uploading file: %@", errorList); + NSError * error = nil; + if (errorList.count > 0) { + NSString *errorMessage = [errorList.firstObject description]; + if ([errorList.firstObject isKindOfClass:NSDictionary.class]) { + NSDictionary *errorInfo = errorList.firstObject; + errorMessage = errorInfo[@"message"]; + } + NSDictionary *errorDictionary = @{NSLocalizedDescriptionKey: errorMessage}; + error = [[NSError alloc] initWithDomain:WordPressComRestApiErrorDomain + code:WordPressComRestApiErrorCodeUploadFailed + userInfo:errorDictionary]; + } + return error; +} + +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSParameterAssert([media isKindOfClass:[RemoteMedia class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/%@", self.siteID, media.mediaID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersFromRemoteMedia:media]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *response) { + RemoteMedia *media = [MediaServiceRemoteREST remoteMediaFromJSONDictionary:responseObject]; + if (success) { + success(media); + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([media isKindOfClass:[RemoteMedia class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/%@/delete", self.siteID, media.mediaID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSString *status = [response stringForKey:@"status"]; + if ([status isEqualToString:@"deleted"]) { + if (success) { + success(); + } + } else { + if (failure) { + NSError *error = [[NSError alloc] initWithDomain:WordPressComRestApiErrorDomain + code:WordPressComRestApiErrorCodeUnknown + userInfo:nil]; + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)isSitePrivate + success:(void (^)(RemoteVideoPressVideo *metadata))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [NSString stringWithFormat:@"videos/%@", videoPressID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + RemoteVideoPressVideo *video = [[RemoteVideoPressVideo alloc] initWithDictionary:response id:videoPressID]; + + BOOL needsToken = video.privacySetting == VideoPressPrivacySettingIsPrivate || (video.privacySetting == VideoPressPrivacySettingSiteDefault && isSitePrivate); + if(needsToken) { + [self getVideoPressToken:videoPressID success:^(NSString *token) { + video.token = token; + if (success) { + success(video); + } + } failure:^(NSError * error) { + if (failure) { + failure(error); + } + }]; + } + else { + if (success) { + success(video); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure +{ + + NSString *path = [NSString stringWithFormat:@"sites/%@/media/videopress-playback-jwt/%@", self.siteID, videoPressID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_2_0]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + NSString *token = [response stringForKey:@"metadata_token"]; + if (token) { + if (success) { + success(token); + } + } else { + if (failure) { + NSError *error = [[NSError alloc] initWithDomain:WordPressComRestApiErrorDomain + code:WordPressComRestApiErrorCodeUnknown + userInfo:nil]; + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *response) { + if (failure) { + failure(error); + } + }]; +} + ++ (NSArray *)remoteMediaFromJSONArray:(NSArray *)jsonMedia +{ + return [jsonMedia wp_map:^id(NSDictionary *json) { + return [self remoteMediaFromJSONDictionary:json]; + }]; +} + ++ (RemoteMedia *)remoteMediaFromJSONDictionary:(NSDictionary *)jsonMedia +{ + RemoteMedia * remoteMedia=[[RemoteMedia alloc] init]; + remoteMedia.mediaID = [jsonMedia numberForKey:@"ID"]; + remoteMedia.url = [NSURL URLWithString:[jsonMedia stringForKey:@"URL"]]; + remoteMedia.guid = [NSURL URLWithString:[jsonMedia stringForKey:@"guid"]]; + remoteMedia.date = [NSDate dateWithWordPressComJSONString:jsonMedia[@"date"]]; + remoteMedia.postID = [jsonMedia numberForKey:@"post_ID"]; + remoteMedia.file = [jsonMedia stringForKey:@"file"]; + remoteMedia.largeURL = [NSURL URLWithString:[jsonMedia valueForKeyPath :@"thumbnails.large"]]; + remoteMedia.mediumURL = [NSURL URLWithString:[jsonMedia valueForKeyPath :@"thumbnails.medium"]]; + remoteMedia.mimeType = [jsonMedia stringForKey:@"mime_type"]; + remoteMedia.extension = [jsonMedia stringForKey:@"extension"]; + remoteMedia.title = [jsonMedia stringForKey:@"title"]; + remoteMedia.caption = [jsonMedia stringForKey:@"caption"]; + remoteMedia.descriptionText = [jsonMedia stringForKey:@"description"]; + remoteMedia.alt = [jsonMedia stringForKey:@"alt"]; + remoteMedia.height = [jsonMedia numberForKey:@"height"]; + remoteMedia.width = [jsonMedia numberForKey:@"width"]; + remoteMedia.exif = [jsonMedia dictionaryForKey:@"exif"]; + remoteMedia.remoteThumbnailURL = [jsonMedia stringForKeyPath:@"thumbnails.fmt_std"]; + remoteMedia.videopressGUID = [jsonMedia stringForKey:@"videopress_guid"]; + remoteMedia.length = [jsonMedia numberForKey:@"length"]; + return remoteMedia; +} + +- (NSDictionary *)parametersFromRemoteMedia:(RemoteMedia *)remoteMedia +{ + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (remoteMedia.postID != nil) { + parameters[@"parent_id"] = remoteMedia.postID; + } + if (remoteMedia.title != nil) { + parameters[@"title"] = remoteMedia.title; + } + + if (remoteMedia.caption != nil) { + parameters[@"caption"] = remoteMedia.caption; + } + + if (remoteMedia.descriptionText != nil) { + parameters[@"description"] = remoteMedia.descriptionText; + } + + if (remoteMedia.alt != nil) { + parameters[@"alt"] = remoteMedia.alt; + } + + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +- (NSDictionary *)parametersForUploadMedia:(RemoteMedia *)media +{ + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (media.caption != nil) { + parameters[@"attrs[0][caption]"] = media.caption; + } + if (media.postID != nil && [media.postID compare:@(0)] == NSOrderedDescending) { + parameters[@"attrs[0][parent_id]"] = media.postID; + } + + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..a1594e70ad72 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.h @@ -0,0 +1,6 @@ +#import +#import +#import + +@interface MediaServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..0d0059eac0e3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MediaServiceRemoteXMLRPC.m @@ -0,0 +1,317 @@ +#import "MediaServiceRemoteXMLRPC.h" +#import "RemoteMedia.h" +#import "WPKit-Swift.h" + +@import WordPressShared; +@import NSObject_SafeExpectations; + +@implementation MediaServiceRemoteXMLRPC + +- (void)getMediaWithID:(NSNumber *)mediaID + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:mediaID]; + [self.api callMethod:@"wp.getMediaItem" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSDictionary * xmlRPCDictionary = (NSDictionary *)responseObject; + RemoteMedia * remoteMedia = [self remoteMediaFromXMLRPCDictionary:xmlRPCDictionary]; + success(remoteMedia); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryWithPageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + [self getMediaLibraryStartOffset:0 media:@[] pageLoad:pageLoad success:success failure:failure]; +} + +- (void)getMediaLibraryStartOffset:(NSUInteger)offset + media:(NSArray *)media + pageLoad:(void (^)(NSArray *))pageLoad + success:(void (^)(NSArray *))success + failure:(void (^)(NSError *))failure +{ + NSInteger pageSize = 100; + NSDictionary *filter = @{ + @"number": @(pageSize), + @"offset": @(offset) + }; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:filter]; + + [self.api callMethod:@"wp.getMediaLibrary" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (!success) { + return; + } + NSArray *pageMedia = [self remoteMediaFromXMLRPCArray:responseObject]; + NSArray *resultMedia = [media arrayByAddingObjectsFromArray:pageMedia]; + // Did we got all the items we requested or it's finished? + if (pageMedia.count < pageSize) { + success(resultMedia); + return; + } + if(pageLoad) { + pageLoad(pageMedia); + } + NSUInteger newOffset = offset + pageSize; + [self getMediaLibraryStartOffset:newOffset media:resultMedia pageLoad:pageLoad success: success failure: failure]; + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getMediaLibraryCountForType:(NSString *)mediaType + withSuccess:(void (^)(NSInteger))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *data = @{}; + if (mediaType) { + data = @{@"filter":@{ @"mime_type": mediaType }}; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:data]; + [self.api callMethod:@"wp.getMediaLibrary" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([responseObject count]); + } + } + failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +- (NSURLCredential *)findCredentialForHost:(NSString *)host port:(NSInteger)port +{ + __block NSURLCredential *foundCredential = nil; + [[[NSURLCredentialStorage sharedCredentialStorage] allCredentials] enumerateKeysAndObjectsUsingBlock:^(NSURLProtectionSpace *ps, NSDictionary *dict, BOOL *stop) { + [dict enumerateKeysAndObjectsUsingBlock:^(id key, NSURLCredential *credential, BOOL *stop) { + if ([[ps host] isEqualToString:host] && [ps port] == port) + + { + foundCredential = credential; + *stop = YES; + } + }]; + if (foundCredential) { + *stop = YES; + } + }]; + return foundCredential; +} + +/** + Adds a basic auth header to a request if a credential is stored for that specific host. + + The credentials will only be added if a set of credentials for the request host are stored on the shared credential storage + @param request The request to where the authentication information will be added. + */ +- (void)addBasicAuthCredentialsIfAvailableToRequest:(NSMutableURLRequest *)request +{ + NSInteger port = [[request.URL port] integerValue]; + if (port == 0) { + port = 80; + } + + NSURLCredential *credential = [self findCredentialForHost:request.URL.host port:port]; + if (credential) { + NSString *authStr = [NSString stringWithFormat:@"%@:%@", [credential user], [credential password]]; + NSData *authData = [authStr dataUsingEncoding:NSUTF8StringEncoding]; + NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodedStringWithOptions:NSDataBase64Encoding64CharacterLineLength]]; + [request setValue:authValue forHTTPHeaderField:@"Authorization"]; + } +} + +- (void)uploadMedia:(RemoteMedia *)media + progress:(NSProgress **)progress + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + NSString *type = media.mimeType; + NSString *filename = media.file; + if (media.localURL == nil || filename == nil || type == nil) { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorFileDoesNotExist + userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Media doesn't have an associated file to upload.", @"Error message to show to users when trying to upload a media object with no local file associated")}]; + failure(error); + } + return; + } + NSMutableDictionary *data = [NSMutableDictionary dictionaryWithDictionary:@{ + @"name": filename, + @"type": type, + @"bits": [NSInputStream inputStreamWithFileAtPath:media.localURL.path], + }]; + if ([media.postID compare:@(0)] == NSOrderedDescending) { + data[@"post_id"] = media.postID; + } + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:data]; + + __block NSProgress *localProgress = [self.api streamCallMethod:@"wp.uploadFile" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = (NSDictionary *)responseObject; + if (![response isKindOfClass:[NSDictionary class]]) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorBadServerResponse userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"The server returned an empty response. This usually means you need to increase the memory limit for your site.", @"")}]; + if (failure) { + failure(error); + } + } else { + localProgress.completedUnitCount=localProgress.totalUnitCount; + RemoteMedia * remoteMedia = [self remoteMediaFromXMLRPCDictionary:response]; + if (success){ + success(remoteMedia); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + + if (progress) { + *progress = localProgress; + } +} + +- (void)updateMedia:(RemoteMedia *)media + success:(void (^)(RemoteMedia *remoteMedia))success + failure:(void (^)(NSError *error))failure +{ + //HACK: Sergio Estevao: 2016-04-06 this option doens't exist on XML-RPC so we will always say that all was good + if (success) { + success(media); + } +} + +- (void)deleteMedia:(RemoteMedia *)media + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([media.mediaID longLongValue] > 0); + + NSArray *parameters = [self XMLRPCArgumentsWithExtra:media.mediaID]; + [self.api callMethod:@"wp.deleteFile" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + BOOL deleted = [responseObject boolValue]; + if (deleted) { + if (success) { + success(); + } + } else { + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorUnknown userInfo:nil]; + failure(error); + } + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getMetadataFromVideoPressID:(NSString *)videoPressID + isSitePrivate:(BOOL)includeToken + success:(void (^)(RemoteVideoPressVideo *video))success + failure:(void (^)(NSError *))failure +{ + // ⚠️ The endpoint used for fetching the metadata is not available in XML-RPC. + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorUnsupportedURL + userInfo:nil]; + failure(error); + } +} + +-(void)getVideoPressToken:(NSString *)videoPressID + success:(void (^)(NSString *token))success + failure:(void (^)(NSError *))failure +{ + // The endpoint `wpcom/v2/sites//media/videopress-playback-jwt/` is not available in XML-RPC. + if (failure) { + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorUnsupportedURL + userInfo:nil]; + failure(error); + } +} + +#pragma mark - Private methods + +- (NSArray *)remoteMediaFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wp_map:^id(NSDictionary *xmlrpcMedia) { + return [self remoteMediaFromXMLRPCDictionary:xmlrpcMedia]; + }]; +} + +- (RemoteMedia *)remoteMediaFromXMLRPCDictionary:(NSDictionary*)xmlRPC +{ + RemoteMedia * remoteMedia = [[RemoteMedia alloc] init]; + remoteMedia.url = [NSURL URLWithString:[xmlRPC stringForKey:@"link"]] ?: [NSURL URLWithString:[xmlRPC stringForKey:@"url"]]; + remoteMedia.title = [xmlRPC stringForKey:@"title"]; + remoteMedia.width = [xmlRPC numberForKeyPath:@"metadata.width"]; + remoteMedia.height = [xmlRPC numberForKeyPath:@"metadata.height"]; + remoteMedia.mediaID = [xmlRPC numberForKey:@"attachment_id"] ?: [xmlRPC numberForKey:@"id"]; + remoteMedia.mimeType = [xmlRPC stringForKeyPath:@"metadata.mime_type"] ?: [xmlRPC stringForKey:@"type"]; + NSString *link = nil; + if ([[xmlRPC objectForKeyPath:@"link"] isKindOfClass:NSDictionary.class]) { + NSDictionary *linkDictionary = (NSDictionary *)[xmlRPC objectForKeyPath:@"link"]; + link = [linkDictionary stringForKeyPath:@"url"]; + } else { + link = [xmlRPC stringForKeyPath:@"link"]; + } + remoteMedia.file = [link lastPathComponent] ?: [[xmlRPC objectForKeyPath:@"file"] lastPathComponent]; + + if ([xmlRPC stringForKeyPath:@"metadata.sizes.large.file"] != nil) { + remoteMedia.largeURL = [NSURL URLWithString: [NSString stringWithFormat:@"%@%@", remoteMedia.url.URLByDeletingLastPathComponent, [xmlRPC stringForKeyPath:@"metadata.sizes.large.file"]]]; + } + + if ([xmlRPC stringForKeyPath:@"metadata.sizes.medium.file"] != nil) { + remoteMedia.mediumURL = [NSURL URLWithString: [NSString stringWithFormat:@"%@%@", remoteMedia.url.URLByDeletingLastPathComponent, [xmlRPC stringForKeyPath:@"metadata.sizes.medium.file"]]]; + } + + if (xmlRPC[@"date_created_gmt"] != nil) { + remoteMedia.date = xmlRPC[@"date_created_gmt"]; + } + + remoteMedia.caption = [xmlRPC stringForKey:@"caption"]; + remoteMedia.descriptionText = [xmlRPC stringForKey:@"description"]; + // Sergio (2017-10-26): This field isn't returned by the XMLRPC API so we assuming empty string + remoteMedia.alt = @""; + remoteMedia.extension = [remoteMedia.file pathExtension]; + remoteMedia.length = [xmlRPC numberForKeyPath:@"metadata.length"]; + + NSNumber *parent = [xmlRPC numberForKeyPath:@"parent"]; + if ([parent integerValue] > 0) { + remoteMedia.postID = parent; + } + + return remoteMedia; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.h new file mode 100644 index 000000000000..1ef6b1daba41 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.h @@ -0,0 +1,97 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +extern NSString * const MenusRemoteKeyID; +extern NSString * const MenusRemoteKeyMenu; +extern NSString * const MenusRemoteKeyMenus; +extern NSString * const MenusRemoteKeyLocations; +extern NSString * const MenusRemoteKeyContentID; +extern NSString * const MenusRemoteKeyDescription; +extern NSString * const MenusRemoteKeyLinkTarget; +extern NSString * const MenusRemoteKeyLinkTitle; +extern NSString * const MenusRemoteKeyName; +extern NSString * const MenusRemoteKeyType; +extern NSString * const MenusRemoteKeyTypeFamily; +extern NSString * const MenusRemoteKeyTypeLabel; +extern NSString * const MenusRemoteKeyURL; +extern NSString * const MenusRemoteKeyItems; +extern NSString * const MenusRemoteKeyDeleted; +extern NSString * const MenusRemoteKeyLocationDefaultState; + +@class RemoteMenu; +@class RemoteMenuItem; +@class RemoteMenuLocation; + +typedef void(^MenusServiceRemoteSuccessBlock)(void); +typedef void(^MenusServiceRemoteMenuRequestSuccessBlock)(RemoteMenu *menu); +typedef void(^MenusServiceRemoteMenusRequestSuccessBlock)(NSArray * _Nullable menus, NSArray * _Nullable locations); +typedef void(^MenusServiceRemoteFailureBlock)(NSError * _Nonnull error); + +@interface MenusServiceRemote : ServiceRemoteWordPressComREST + +#pragma mark - Remote queries: Creating and modifying menus + +/** + * @brief Create a new menu on a blog. + * + * @param menuName The name of the new menu to be created. Cannot be nil. + * @param siteID The site ID to create the menu on. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)createMenuWithName:(NSString *)menuName + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +/** + * @brief Update a menu on a blog. + * + * @param menuID The updated menu object to update remotely. Cannot be nil. + * @param siteID The site ID to update the menu on. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)updateMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + withName:(nullable NSString *)updatedName + withLocations:(nullable NSArray *)locationNames + withItems:(nullable NSArray *)updatedItems + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +/** + * @brief Delete a menu from a blog. + * + * @param menuID The menuId of the menu to delete remotely. Cannot be nil. + * @param siteID The site ID to delete the menu from. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)deleteMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +#pragma mark - Remote queries: Getting menus + +/** + * @brief Gets the available menus for a specific blog. + * + * @param siteID The site ID to get the available menus for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + */ +- (void)getMenusForSiteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenusRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.m new file mode 100644 index 000000000000..e7c0b1453992 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/MenusServiceRemote.m @@ -0,0 +1,422 @@ +#import "MenusServiceRemote.h" +#import "WPKit-Swift.h" +@import WordPressShared; +@import NSObject_SafeExpectations; + +NS_ASSUME_NONNULL_BEGIN + +NSString * const MenusRemoteKeyID = @"id"; +NSString * const MenusRemoteKeyMenu = @"menu"; +NSString * const MenusRemoteKeyMenus = @"menus"; +NSString * const MenusRemoteKeyLocations = @"locations"; +NSString * const MenusRemoteKeyContentID = @"content_id"; +NSString * const MenusRemoteKeyDescription = @"description"; +NSString * const MenusRemoteKeyLinkTarget = @"link_target"; +NSString * const MenusRemoteKeyLinkTitle = @"link_title"; +NSString * const MenusRemoteKeyName = @"name"; +NSString * const MenusRemoteKeyType = @"type"; +NSString * const MenusRemoteKeyTypeFamily = @"type_family"; +NSString * const MenusRemoteKeyTypeLabel = @"type_label"; +NSString * const MenusRemoteKeyURL = @"url"; +NSString * const MenusRemoteKeyItems = @"items"; +NSString * const MenusRemoteKeyDeleted = @"deleted"; +NSString * const MenusRemoteKeyLocationDefaultState = @"defaultState"; +NSString * const MenusRemoteKeyClasses = @"classes"; + +@implementation MenusServiceRemote + +#pragma mark - Remote queries: Creating and modifying menus + +- (void)createMenuWithName:(NSString *)menuName + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuName isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/new", siteID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestURL + parameters:@{MenusRemoteKeyName: menuName} + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred creating the Menu.", @"An error description explaining that a Menu could not be created."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + NSNumber *menuID = [responseObject numberForKey:MenusRemoteKeyID]; + if (!menuID) { + responseFailure(); + return; + } + if (success) { + RemoteMenu *menu = [RemoteMenu new]; + menu.menuID = menuID; + menu.name = menuName; + success(menu); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + withName:(nullable NSString *)updatedName + withLocations:(nullable NSArray *)locationNames + withItems:(nullable NSArray *)updatedItems + success:(nullable MenusServiceRemoteMenuRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/%@", siteID, menuID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSMutableDictionary *params = [NSMutableDictionary dictionaryWithCapacity:2]; + if (updatedName.length) { + [params setObject:updatedName forKey:MenusRemoteKeyName]; + } + if (updatedItems.count) { + [params setObject:[self menuItemJSONDictionariesFromMenuItems:updatedItems] forKey:MenusRemoteKeyItems]; + } + if (locationNames.count) { + [params setObject:locationNames forKey:MenusRemoteKeyLocations]; + } + + // temporarily need to force the id for the menu update to work until fixed in Jetpack endpoints + // Brent Coursey - 10/1/2015 + [params setObject:menuID forKey:MenusRemoteKeyID]; + + [self.wordPressComRESTAPI post:requestURL + parameters:params + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred updating the Menu.", @"An error description explaining that a Menu could not be updated."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + if (![responseObject isKindOfClass:[NSDictionary class]]) { + responseFailure(); + return; + } + RemoteMenu *menu = [self menuFromJSONDictionary:[responseObject dictionaryForKey:MenusRemoteKeyMenu]]; + if (!menu) { + responseFailure(); + return; + } + if (success) { + success(menu); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteMenuForID:(NSNumber *)menuID + siteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + NSParameterAssert([menuID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus/%@/delete", siteID, menuID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + [self.wordPressComRESTAPI post:requestURL + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + void(^responseFailure)(void) = ^() { + NSString *message = NSLocalizedString(@"An error occurred deleting the Menu.", @"An error description explaining that a Menu could not be deleted."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + }; + if (![responseObject isKindOfClass:[NSDictionary class]]) { + responseFailure(); + return; + } + BOOL deleted = [[responseObject numberForKey:MenusRemoteKeyDeleted] boolValue]; + if (deleted) { + if (success) { + success(); + } + } else { + responseFailure(); + } + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Remote queries: Getting menus + +- (void)getMenusForSiteID:(NSNumber *)siteID + success:(nullable MenusServiceRemoteMenusRequestSuccessBlock)success + failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + NSParameterAssert([siteID isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/menus", siteID]; + NSString *requestURL = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestURL + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = NSLocalizedString(@"An error occurred fetching the Menus.", @"An error description explaining that Menus could not be fetched."); + [self handleResponseErrorWithMessage:message url:requestURL failure:failure]; + return; + } + if (success) { + NSArray *menus = [self remoteMenusFromJSONArray:[responseObject arrayForKey:MenusRemoteKeyMenus]]; + NSArray *locations = [self remoteMenuLocationsFromJSONArray:[responseObject arrayForKey:MenusRemoteKeyLocations]]; + success(menus, locations); + } + + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Remote Model from JSON + +- (nullable NSArray *)remoteMenusFromJSONArray:(nullable NSArray *)jsonMenus +{ + return [jsonMenus wp_map:^id(NSDictionary *dictionary) { + return [self menuFromJSONDictionary:dictionary]; + }]; +} + +- (nullable NSArray *)menuItemsFromJSONDictionaries:(nullable NSArray *)dictionaries parent:(nullable RemoteMenuItem *)parent +{ + NSParameterAssert([dictionaries isKindOfClass:[NSArray class]]); + return [dictionaries wp_map:^id(NSDictionary *dictionary) { + + RemoteMenuItem *item = [self menuItemFromJSONDictionary:dictionary]; + item.parentItem = parent; + + return item; + }]; +} + +- (nullable NSArray *)remoteMenuLocationsFromJSONArray:(nullable NSArray *)jsonLocations +{ + return [jsonLocations wp_map:^id(NSDictionary *dictionary) { + return [self menuLocationFromJSONDictionary:dictionary]; + }]; +} + +/** + * @brief Creates a remote menu object from the specified dictionary with nested menu items. + * + * @param dictionary The dictionary containing the menu information. Cannot be nil. + * + * @returns A remote menu object. + */ +- (nullable RemoteMenu *)menuFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]]) { + return nil; + } + + NSNumber *menuID = [dictionary numberForKey:MenusRemoteKeyID]; + if (!menuID.integerValue) { + // empty menu dictionary + return nil; + } + + RemoteMenu *menu = [RemoteMenu new]; + menu.menuID = menuID; + menu.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + menu.name = [dictionary stringForKey:MenusRemoteKeyName]; + menu.locationNames = [dictionary arrayForKey:MenusRemoteKeyLocations]; + + NSArray *itemDicts = [dictionary arrayForKey:MenusRemoteKeyItems]; + if (itemDicts.count) { + menu.items = [self menuItemsFromJSONDictionaries:itemDicts parent:nil]; + } + + return menu; +} + +/** + * @brief Creates a remote menu item object from the specified dictionary along with any child items. + * + * @param dictionary The dictionary containing the menu items. Cannot be nil. + * + * @returns A remote menu item object. + */ +- (nullable RemoteMenuItem *)menuItemFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]] || !dictionary.count) { + return nil; + } + + RemoteMenuItem *item = [RemoteMenuItem new]; + item.itemID = [dictionary numberForKey:MenusRemoteKeyID]; + item.contentID = [dictionary numberForKey:MenusRemoteKeyContentID]; + item.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + item.linkTarget = [dictionary stringForKey:MenusRemoteKeyLinkTarget]; + item.linkTitle = [dictionary stringForKey:MenusRemoteKeyLinkTitle]; + item.name = [dictionary stringForKey:MenusRemoteKeyName]; + item.type = [dictionary stringForKey:MenusRemoteKeyType]; + item.typeFamily = [dictionary stringForKey:MenusRemoteKeyTypeFamily]; + item.typeLabel = [dictionary stringForKey:MenusRemoteKeyTypeLabel]; + item.urlStr = [dictionary stringForKey:MenusRemoteKeyURL]; + item.classes = [dictionary arrayForKey:MenusRemoteKeyClasses]; + + NSArray *itemDicts = [dictionary arrayForKey:MenusRemoteKeyItems]; + if (itemDicts.count) { + item.children = [self menuItemsFromJSONDictionaries:itemDicts parent:item]; + } + + return item; +} + +/** + * @brief Creates a remote menu location object from the specified dictionary. + * + * @param dictionary The dictionary containing the locations. Cannot be nil. + * + * @returns A remote menu location object. + */ +- (nullable RemoteMenuLocation *)menuLocationFromJSONDictionary:(nullable NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + if (![dictionary isKindOfClass:[NSDictionary class]] || !dictionary.count) { + return nil; + } + + RemoteMenuLocation *location = [RemoteMenuLocation new]; + location.defaultState = [dictionary stringForKey:MenusRemoteKeyLocationDefaultState]; + location.details = [dictionary stringForKey:MenusRemoteKeyDescription]; + location.name = [dictionary stringForKey:MenusRemoteKeyName]; + + return location; +} + +#pragma mark - Remote model to JSON + +/** + * @brief Creates remote menu item JSON dictionaries from the remote menu item objects. + * + * @param menuItems The array containing the menu items. Cannot be nil. + * + * @returns An array with menu item JSON dictionary representations. + */ +- (NSArray *)menuItemJSONDictionariesFromMenuItems:(NSArray *)menuItems +{ + NSMutableArray *dictionaries = [NSMutableArray arrayWithCapacity:menuItems.count]; + for (RemoteMenuItem *item in menuItems) { + [dictionaries addObject:[self menuItemJSONDictionaryFromItem:item]]; + } + + return [NSArray arrayWithArray:dictionaries]; +} + +/** + * @brief Creates a remote menu item JSON dictionary from the remote menu item object, with nested item dictionaries. + * + * @param item The remote menu item object. Cannot be nil. + * + * @returns A JSON dictionary representation of the menu item object. + */ +- (NSDictionary *)menuItemJSONDictionaryFromItem:(RemoteMenuItem *)item +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + + if (item.itemID.integerValue) { + dictionary[MenusRemoteKeyID] = item.itemID; + } + + if (item.contentID.integerValue) { + dictionary[MenusRemoteKeyContentID] = item.contentID; + } + + if (item.details.length) { + dictionary[MenusRemoteKeyDescription] = item.details; + } + + if (item.linkTarget.length) { + dictionary[MenusRemoteKeyLinkTarget] = item.linkTarget; + } + + if (item.linkTitle.length) { + dictionary[MenusRemoteKeyLinkTitle] = item.linkTitle; + } + + if (item.name.length) { + dictionary[MenusRemoteKeyName] = item.name; + } + + if (item.type.length) { + dictionary[MenusRemoteKeyType] = item.type; + } + + if (item.typeFamily.length) { + dictionary[MenusRemoteKeyTypeFamily] = item.typeFamily; + } + + if (item.typeLabel.length) { + dictionary[MenusRemoteKeyTypeLabel] = item.typeLabel; + } + + if (item.urlStr.length) { + dictionary[MenusRemoteKeyURL] = item.urlStr; + } + + if (item.classes.count) { + dictionary[MenusRemoteKeyClasses] = item.classes; + } + + if (item.children.count) { + + NSMutableArray *dictionaryItems = [NSMutableArray arrayWithCapacity:item.children.count]; + for (RemoteMenuItem *remoteItem in item.children) { + [dictionaryItems addObject:[self menuItemJSONDictionaryFromItem:remoteItem]]; + } + + dictionary[MenusRemoteKeyItems] = [NSArray arrayWithArray:dictionaryItems]; + } + + return [NSDictionary dictionaryWithDictionary:dictionary]; +} + +- (NSDictionary *)menuLocationJSONDictionaryFromLocation:(RemoteMenuLocation *)location +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + [dictionary setObject:MenusRemoteKeyName forKey:location.name]; + + return [NSDictionary dictionaryWithDictionary:dictionary]; +} + +#pragma mark - errors + +- (void)handleResponseErrorWithMessage:(NSString *)message url:(NSString *)urlStr failure:(nullable MenusServiceRemoteFailureBlock)failure +{ + WPKitLogError(@"%@ - URL: %@", message, urlStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/NotificationSettingsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/NotificationSettingsServiceRemote.swift new file mode 100644 index 000000000000..5e33682a990b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/NotificationSettingsServiceRemote.swift @@ -0,0 +1,140 @@ +import Foundation +import UIDeviceIdentifier +import WordPressShared + +/// The purpose of this class is to encapsulate all of the interaction with the Notifications REST endpoints. +/// Here we'll deal mostly with the Settings / Push Notifications API. +/// +open class NotificationSettingsServiceRemote: ServiceRemoteWordPressComREST { + /// Designated Initializer. Fails if the remoteApi is nil. + /// + /// - Parameter wordPressComRestApi: A Reference to the WordPressComRestApi that should be used to interact with WordPress.com + /// + public override init(wordPressComRestApi: WordPressComRestApi) { + super.init(wordPressComRestApi: wordPressComRestApi) + } + + /// Retrieves all of the Notification Settings + /// + /// - Parameters: + /// - deviceId: The ID of the current device. Can be nil. + /// - success: A closure to be called on success, which will receive the parsed settings entities. + /// - failure: Optional closure to be called on failure. Will receive the error that was encountered. + /// + open func getAllSettings(_ deviceId: String, success: (([RemoteNotificationSettings]) -> Void)?, failure: ((NSError?) -> Void)?) { + let path = String(format: "me/notifications/settings/?device_id=%@", deviceId) + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + wordPressComRESTAPI.get(requestUrl, + parameters: nil, + success: { response, _ in + let settings = RemoteNotificationSettings.fromDictionary(response as? NSDictionary) + success?(settings) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Updates the specified Notification Settings + /// + /// - Parameters: + /// - settings: The complete (or partial) dictionary of settings to be updated. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func updateSettings(_ settings: [String: AnyObject], success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + let path = String(format: "me/notifications/settings/") + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = settings + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Registers a given Apple Push Token in the WordPress.com Backend. + /// + /// - Parameters: + /// - token: The token of the device to be registered. + /// - pushNotificationAppId: The app id to be registered. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func registerDeviceForPushNotifications(_ token: String, pushNotificationAppId: String, success: ((_ deviceId: String) -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "devices/new" + let requestUrl = path(forEndpoint: endpoint, withVersion: ._1_1) + + let device = UIDevice.current + let parameters = [ + "device_token": token, + "device_family": "apple", + "app_secret_key": pushNotificationAppId, + "device_name": device.name, + "device_model": UIDeviceHardware.platform(), + "os_version": device.systemVersion, + "app_version": Bundle.main.bundleVersion(), + "device_uuid": device.wordPressIdentifier() + ] + + wordPressComRESTAPI.post(requestUrl, + parameters: parameters as [String: Any], + success: { response, _ in + if let responseDict = response as? NSDictionary, + let rawDeviceId = responseDict.object(forKey: "ID") { + // Failsafe: Make sure deviceId is always a string + let deviceId = String(format: "\(rawDeviceId)") + success?(deviceId) + } else { + let innerError = Error.invalidResponse + let outerError = NSError(domain: innerError.domain, code: innerError.code, userInfo: nil) + + failure?(outerError) + } + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Unregisters a given DeviceID for Push Notifications + /// + /// - Parameters: + /// - deviceId: The ID of the device to be unregistered. + /// - success: Optional closure to be called on success. + /// - failure: Optional closure to be called on failure. + /// + @objc open func unregisterDeviceForPushNotifications(_ deviceId: String, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = String(format: "devices/%@/delete", deviceId) + let requestUrl = path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(requestUrl, + parameters: nil, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Describes all of the possible errors that might be generated by this class. + /// + public enum Error: Int { + case invalidResponse = -1 + + var code: Int { + return rawValue + } + + var domain: String { + return "NotificationSettingsServiceRemote" + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/NotificationSyncServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/NotificationSyncServiceRemote.swift new file mode 100644 index 000000000000..53c8c38895f4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/NotificationSyncServiceRemote.swift @@ -0,0 +1,184 @@ +import Foundation + +// MARK: - NotificationSyncServiceRemote +// +public class NotificationSyncServiceRemote: ServiceRemoteWordPressComREST { + // MARK: - Constants + // + private let defaultPageSize = 100 + + // MARK: - Errors + // + public enum SyncError: Error { + case failed + } + + public enum InputError: Int, Error { + case notificationIDsNotProvided + } + + /// Retrieves latest Notifications (OR collection of Notifications, whenever noteIds is present) + /// + /// - Parameters: + /// - pageSize: Number of hashes to retrieve. + /// - noteIds: Identifiers of notifications to retrieve. + /// - completion: callback to be executed on completion. + /// + /// + public func loadNotes(withPageSize pageSize: Int? = nil, noteIds: [String]? = nil, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let fields = "id,note_hash,type,unread,body,subject,timestamp,meta" + + loadNotes(withNoteIds: noteIds, fields: fields, pageSize: pageSize) { error, notes in + completion(error, notes) + } + } + + /// Retrieves the Notification Hashes for the specified pageSize (OR collection of NoteID's, when present) + /// + /// - Parameters: + /// - pageSize: Number of hashes to retrieve. + /// - noteIds: Identifiers of notifications to retrieve. + /// - completion: callback to be executed on completion. + /// + /// - Notes: The RemoteNotification Entity will only have it's ID + Hash populated + /// + public func loadHashes(withPageSize pageSize: Int? = nil, noteIds: [String]? = nil, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let fields = "id,note_hash" + + loadNotes(withNoteIds: noteIds, fields: fields, pageSize: pageSize) { error, notes in + completion(error, notes) + } + } + + /// Updates a Notification's Read Status as specified. + /// + /// - Parameters: + /// - notificationID: The NotificationID to Mark as Read. + /// - read: The new Read Status to set. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateReadStatus(_ notificationID: String, read: Bool, completion: @escaping ((Error?) -> Void)) { + updateReadStatusForNotifications([notificationID], read: read, completion: completion) + } + + /// Updates an array of Notifications' Read Status as specified. + /// + /// - Parameters: + /// - notificationIDs: ID's of Notifications to Mark as Read. + /// - read: The new Read Status to set. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateReadStatusForNotifications(_ notificationIDs: [String], read: Bool, completion: @escaping ((Error?) -> Void)) { + guard !notificationIDs.isEmpty else { + completion(InputError.notificationIDsNotProvided) + return + } + + let path = "notifications/read" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + // Note: Isn't the API wonderful? + let value = read ? 9999 : -9999 + + var notifications: [String: Int] = [:] + + for notificationID in notificationIDs { + notifications[notificationID] = value + } + + let parameters = ["counts": notifications] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters as [String: AnyObject]?, success: { (response, _) in + let error = self.errorFromResponse(response) + completion(error) + + }, failure: { (error, _) in + completion(error) + }) + } + + /// Updates the Last Seen Notification's Timestamp. + /// + /// - Parameters: + /// - timestamp: Timestamp of the last seen notification. + /// - completion: Closure to be executed on completion, indicating whether the OP was successful or not. + /// + @objc public func updateLastSeen(_ timestamp: String, completion: @escaping ((Error?) -> Void)) { + let path = "notifications/seen" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = [ + "time": timestamp + ] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters as [String: AnyObject]?, success: { (response, _) in + let error = self.errorFromResponse(response) + completion(error) + + }, failure: { (error, _) in + completion(error) + }) + } +} + +// MARK: - Private Methods +// +private extension NotificationSyncServiceRemote { + /// Attempts to parse the `success` field of a given response. When it's missing, or it's false, + /// this method will return SyncError.failed. + /// + /// - Parameter response: JSON entity , as retrieved from the backend. + /// + /// - Returns: SyncError.failed whenever the success field is either missing, or set to false. + /// + func errorFromResponse(_ response: Any) -> Error? { + let document = response as? [String: AnyObject] + let success = document?["success"] as? Bool + guard success != true else { + return nil + } + + return SyncError.failed + } + + /// Retrieves the Notification for the specified pageSize (OR collection of NoteID's, when present). + /// Note that only the specified fields will be retrieved. + /// + /// - Parameters: + /// - noteIds: Identifier for the notifications that should be loaded. + /// - fields: List of comma separated fields, to be loaded. + /// - pageSize: Number of notifications to load. + /// - completion: Callback to be executed on completion. + /// + func loadNotes(withNoteIds noteIds: [String]? = nil, fields: String? = nil, pageSize: Int?, completion: @escaping ((Error?, [RemoteNotification]?) -> Void)) { + let path = "notifications/" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [ + "number": pageSize as AnyObject? ?? defaultPageSize as AnyObject + ] + + if let notificationIds = noteIds { + parameters["ids"] = (notificationIds as NSArray).componentsJoined(by: ",") as AnyObject? + } + + if let fields = fields { + parameters["fields"] = fields as AnyObject? + } + + wordPressComRESTAPI.get(requestUrl, parameters: parameters, success: { response, _ in + let document = response as? [String: AnyObject] + let notes = document?["notes"] as? [[String: AnyObject]] + let parsed = notes?.compactMap { RemoteNotification(document: $0) } + + if let parsed = parsed { + completion(nil, parsed) + } else { + completion(SyncError.failed, nil) + } + + }, failure: { error, _ in + completion(error, nil) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PageLayoutServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/PageLayoutServiceRemote.swift new file mode 100644 index 000000000000..534005583648 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PageLayoutServiceRemote.swift @@ -0,0 +1,32 @@ +import Foundation + +public class PageLayoutServiceRemote { + + public typealias CompletionHandler = (Swift.Result) -> Void + public static func fetchLayouts(_ api: WordPressComRestApi, forBlogID blogID: Int?, withParameters parameters: [String: AnyObject]?, completion: @escaping CompletionHandler) { + let urlPath: String + if let blogID = blogID { + urlPath = "/wpcom/v2/sites/\(blogID)/block-layouts" + } else { + urlPath = "/wpcom/v2/common-block-layouts" + } + + api.GET(urlPath, parameters: parameters, success: { (responseObject, _) in + guard let result = parseLayouts(fromResponse: responseObject) else { + let error = NSError(domain: "PageLayoutService", code: 0, userInfo: [NSDebugDescriptionErrorKey: "Unable to parse response"]) + completion(.failure(error)) + return + } + completion(.success(result)) + }, failure: { (error, _) in + completion(.failure(error)) + }) + } + + private static func parseLayouts(fromResponse response: Any) -> RemotePageLayouts? { + guard let data = try? JSONSerialization.data(withJSONObject: response) else { + return nil + } + return try? JSONDecoder().decode(RemotePageLayouts.self, from: data) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PeopleServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/PeopleServiceRemote.swift new file mode 100644 index 000000000000..dd7d62af4a48 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PeopleServiceRemote.swift @@ -0,0 +1,638 @@ +import Foundation +import WordPressShared + +/// Encapsulates all of the People Management WordPress.com Methods +/// +public class PeopleServiceRemote: ServiceRemoteWordPressComREST { + + /// Defines the PeopleServiceRemote possible errors. + /// + public enum ResponseError: Error { + case decodingFailure + case invalidInputError + case userAlreadyHasRoleError + case unknownError + } + + /// Retrieves the collection of users associated to a given Site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - offset: The first N users to be skipped in the returned array. + /// - count: Number of objects to retrieve. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Users. + /// + public func getUsers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ users: [User], _ hasMore: Bool) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/users" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "offset": offset as AnyObject, + "order_by": "display_name" as AnyObject, + "order": "ASC" as AnyObject, + "fields": "ID, nice_name, first_name, last_name, name, avatar_URL, roles, is_super_admin, linked_user_ID" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let users = response["users"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(users, siteID: siteID, type: User.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Retrieves the collection of Followers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - count: The first N followers to be skipped in the returned array. + /// - size: Number of objects to retrieve. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Followers. + /// + public func getFollowers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ followers: [Follower], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/follows" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let pageNumber = (offset / count + 1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "page": pageNumber as AnyObject, + "fields": "ID, nice_name, first_name, last_name, name, avatar_URL" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let followers = response["users"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(followers, siteID: siteID, type: Follower.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Retrieves the collection of email followers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - page: The page to fetch. + /// - max: The max number of followers to fetch. + /// - success: Closure to be executed on success with an array of EmailFollower and a bool indicating if more pages are available. + /// - failure: Closure to be executed on error. + /// + public func getEmailFollowers(_ siteID: Int, + page: Int = 1, + max: Int = 20, + success: @escaping ((_ followers: [EmailFollower], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/stats/followers" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "page": page as AnyObject, + "max": max as AnyObject, + "type": "email" as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject], + let subscribers = response["subscribers"] as? [[String: AnyObject]], + let totalPages = response["pages"] as? Int else { + failure(ResponseError.decodingFailure) + return + } + let followers = subscribers.compactMap { EmailFollower(siteID: siteID, statsFollower: StatsFollower(jsonDictionary: $0)) } + let hasMore = totalPages > page + success(followers, hasMore) + }, failure: { error, _ in + failure(error) + }) + } + + /// Retrieves the collection of Viewers associated to a site. + /// + /// - Parameters: + /// - siteID: The target site's ID. + /// - count: The first N followers to be skipped in the returned array. + /// - size: Number of objects to retrieve. + /// - success: Closure to be executed on success + /// - failure: Closure to be executed on error. + /// + /// - Returns: An array of Followers. + /// + public func getViewers(_ siteID: Int, + offset: Int = 0, + count: Int, + success: @escaping ((_ followers: [Viewer], _ hasMore: Bool) -> Void), + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/viewers" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let pageNumber = (offset / count + 1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "page": pageNumber as AnyObject + ] + + wordPressComRESTAPI.get(path, parameters: parameters, success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject], + let viewers = response["viewers"] as? [[String: AnyObject]], + let people = try? self.peopleFromResponse(viewers, siteID: siteID, type: Viewer.self) else { + failure(ResponseError.decodingFailure) + return + } + + let hasMore = self.peopleFoundFromResponse(response) > (offset + people.count) + success(people, hasMore) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Updates a specified User's Role + /// + /// - Parameters: + /// - siteID: The ID of the site associated + /// - personID: The ID of the person to be updated + /// - newRole: The new Role that should be assigned to the user. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + /// - Returns: A single User instance. + /// + public func updateUserRole(_ siteID: Int, + userID: Int, + newRole: String, + success: ((RemotePerson) -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/users/\(userID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["roles": [newRole]] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let person = try? self.personFromResponse(response, siteID: siteID, type: User.self) else { + failure?(ResponseError.decodingFailure) + return + } + + success?(person) + }, + failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes a User from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the user to be deleted. + /// - reassignID: When present, all of the posts and pages that belong to `userID` will be reassigned + /// to another person, with the specified ID. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + public func deleteUser(_ siteID: Int, + userID: Int, + reassignID: Int? = nil, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/users/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + var parameters = [String: AnyObject]() + + if let reassignID = reassignID { + parameters["reassign"] = reassignID as AnyObject? + } + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes a Follower from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the follower to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteFollower(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/followers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Deletes or removes an Email Follower from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the email follower to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteEmailFollower(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/email-followers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { _, _ in + success?() + }, failure: { error, _ in + failure?(error) + }) + } + + /// Deletes or removes a User from a site. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - userID: The ID of the viewer to be deleted. + /// - success: Optional closure to be executed on success + /// - failure: Optional closure to be executed on error. + /// + @objc public func deleteViewer(_ siteID: Int, + userID: Int, + success: (() -> Void)? = nil, + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/viewers/\(userID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (_, _) in + success?() + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Retrieves all of the Available Roles, for a given SiteID. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - success: Optional closure to be executed on success. + /// - failure: Optional closure to be executed on error. + /// + /// - Returns: An array of Person.Role entities. + /// + public func getUserRoles(_ siteID: Int, + success: @escaping (([RemoteRole]) -> Void), + failure: ((Error) -> Void)? = nil) { + let endpoint = "sites/\(siteID)/roles" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let roles = try? self.rolesFromResponse(response) else { + failure?(ResponseError.decodingFailure) + return + } + + success(roles) + }, failure: { (error, _) in + failure?(error) + }) + } + + /// Validates Invitation Recipients. + /// + /// - Parameters: + /// - siteID: The ID of the site associated. + /// - usernameOrEmail: Recipient that should be validated. + /// - role: Role that would be granted to the recipient. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + @objc public func validateInvitation(_ siteID: Int, + usernameOrEmail: String, + role: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/validate" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters = [ + "invitees": usernameOrEmail, + "role": role + ] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + if let error = self.errorFromInviteResponse(responseDict, usernameOrEmail: usernameOrEmail) { + failure(error) + return + } + + success() + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Sends an Invitation to the specified recipient. + /// + /// - Parameters: + /// - siteID: The ID of the associated site. + /// - usernameOrEmail: Recipient that should receive the invite. + /// - role: Role that would be granted to the recipient. + /// - message: String that should be sent to the recipient. + /// - success: Closure to be executed on success. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + @objc public func sendInvitation(_ siteID: Int, + usernameOrEmail: String, + role: String, + message: String, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let parameters = [ + "invitees": usernameOrEmail, + "role": role, + "message": message + ] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject]?, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + if let error = self.errorFromInviteResponse(responseDict, usernameOrEmail: usernameOrEmail) { + failure(error) + return + } + + success() + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Fetch any existing invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links. + /// - success: A success block accepting an array of invite links as an argument. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + public func fetchInvites(_ siteID: Int, + success: @escaping (([RemoteInviteLink]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let params = [ + "status": "all", + "number": 100 + ] as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: params, success: { (responseObject, _) in + guard let responseDict = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + + var results = [RemoteInviteLink]() + if let links = responseDict["links"] as? [[String: Any]] { + for link in links { + results.append(RemoteInviteLink(dict: link)) + } + } + success(results) + + }, failure: { (error, _) in + failure(error) + }) + } + + /// Create a new batch of invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links. + /// - success: A success block accepting an array of invite links as an argument. + /// - failure: Closure to be executed on failure. The remote error will be passed on. + /// + public func generateInviteLinks(_ siteID: Int, + success: @escaping (([RemoteInviteLink]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/links/generate" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + guard let responseArray = responseObject as? [[String: AnyObject]] else { + failure(ResponseError.decodingFailure) + return + } + + var results = [RemoteInviteLink]() + for dict in responseArray { + results.append(RemoteInviteLink(dict: dict)) + } + success(results) + + }, failure: { (error, _) in + failure(error) + }) + + } + + /// Disable any existing invite links. + /// + /// - Parameters: + /// - siteID: The site ID for the invite links to disable. + /// - success: A success block. + /// - failure: A failure block + /// + public func disableInviteLinks(_ siteID: Int, + success: @escaping (([String]) -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = "sites/\(siteID)/invites/links/disable" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: nil, success: { (responseObject, _) in + let deletedKeys = responseObject as? [String] ?? [String]() + success(deletedKeys) + + }, failure: { (error, _) in + failure(error) + }) + } + +} + +/// Encapsulates PeopleServiceRemote Private Methods +/// +private extension PeopleServiceRemote { + /// Parses a dictionary containing an array of RemotePersons, and returns an array of RemotePersons instances. + /// + /// - Parameters: + /// - response: Raw array of entity dictionaries + /// - siteID: the ID of the site associated + /// - type: The kind of Person we should parse. + /// + /// - Returns: An array of *RemotePerson* instances. + /// + func peopleFromResponse(_ rawPeople: [[String: AnyObject]], + siteID: Int, + type: T.Type) throws -> [T] { + let people = try rawPeople.compactMap { (user) -> T? in + return try personFromResponse(user, siteID: siteID, type: type) + } + + return people + } + + /// Parses a dictionary representing a RemotePerson, and returns an instance. + /// + /// - Parameters: + /// - response: Raw backend dictionary + /// - siteID: the ID of the site associated + /// - type: The kind of Person we should parse. + /// + /// - Returns: A single *Person* instance. + /// + func personFromResponse(_ user: [String: AnyObject], + siteID: Int, + type: T.Type) throws -> T { + guard let ID = user["ID"] as? Int else { + throw ResponseError.decodingFailure + } + + guard let username = user["nice_name"] as? String else { + throw ResponseError.decodingFailure + } + + guard let displayName = user["name"] as? String else { + throw ResponseError.decodingFailure + } + + let firstName = user["first_name"] as? String + let lastName = user["last_name"] as? String + let avatarURL = (user["avatar_URL"] as? NSString) + .flatMap { URL(string: $0.byUrlEncoding())} + + let linkedUserID = user["linked_user_ID"] as? Int ?? ID + let isSuperAdmin = user["is_super_admin"] as? Bool ?? false + let roles = user["roles"] as? [String] + + let role = roles?.first ?? "" + + return T(ID: ID, + username: username, + firstName: firstName, + lastName: lastName, + displayName: displayName, + role: role, + siteID: siteID, + linkedUserID: linkedUserID, + avatarURL: avatarURL, + isSuperAdmin: isSuperAdmin) + } + + /// Returns the count of persons that can be retrieved from the backend. + /// + /// - Parameters response: Raw backend dictionary + /// + func peopleFoundFromResponse(_ response: [String: AnyObject]) -> Int { + return response["found"] as? Int ?? 0 + } + + /// Parses a collection of Roles, and returns instances of the RemotePerson.Role Enum. + /// + /// - Parameter roles: Raw backend dictionary + /// + /// - Returns: Collection of the remote roles. + /// + func rolesFromResponse(_ roles: [String: AnyObject]) throws -> [RemoteRole] { + guard let rawRoles = roles["roles"] as? [[String: AnyObject]] else { + throw ResponseError.decodingFailure + } + + let parsed = try rawRoles.map { (rawRole) -> RemoteRole in + guard let name = rawRole["name"] as? String, + let displayName = rawRole["display_name"] as? String else { + throw ResponseError.decodingFailure + } + + return RemoteRole(slug: name, name: displayName) + } + + return parsed + } + + /// Parses a remote Invitation Error into a PeopleServiceRemote.Error. + /// + /// - Parameters: + /// - response: Raw backend dictionary + /// - usernameOrEmail: Recipient that was used to either validate, or effectively send an invite. + /// + /// - Returns: The remote error, if any. + /// + func errorFromInviteResponse(_ response: [String: AnyObject], usernameOrEmail: String) -> Error? { + guard let errors = response["errors"] as? [String: AnyObject], + let theError = errors[usernameOrEmail] as? [String: String], + let code = theError["code"] else { + return nil + } + + switch code { + case "invalid_input": + return ResponseError.invalidInputError + case "invalid_input_has_role": + return ResponseError.userAlreadyHasRoleError + case "invalid_input_following": + return ResponseError.userAlreadyHasRoleError + default: + return ResponseError.unknownError + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote.swift new file mode 100644 index 000000000000..d654f394bf7e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote.swift @@ -0,0 +1,212 @@ +import Foundation +import WordPressShared + +public class PlanServiceRemote: ServiceRemoteWordPressComREST { + public typealias AvailablePlans = (plans: [RemoteWpcomPlan], groups: [RemotePlanGroup], features: [RemotePlanFeature]) + + typealias EndpointResponse = [String: AnyObject] + + public enum ResponseError: Int, Error { + // Error decoding JSON + case decodingFailure + // Depricated. An unsupported plan. + case unsupportedPlan + // Deprecated. No active plan identified in the results. + case noActivePlan + } + + // MARK: - Endpoints + + /// Get the list of WordPress.com plans, their descriptions, and their features. + /// + public func getWpcomPlans(_ success: @escaping (AvailablePlans) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "plans/mobile" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { + response, _ in + + guard let response = response as? EndpointResponse else { + failure(PlanServiceRemote.ResponseError.decodingFailure) + return + } + + let plans = self.parseWpcomPlans(response) + let groups = self.parseWpcomPlanGroups(response) + let features = self.parseWpcomPlanFeatures(response) + + success((plans, groups, features)) + }, failure: { + error, _ in + failure(error) + }) + } + + /// Fetch the plan ID and name for each of the user's sites. + /// Accepts locale as a parameter in order to override automatic localization + /// and return non-localized results when needed. + /// + public func getPlanDescriptionsForAllSitesForLocale(_ locale: String, success: @escaping ([Int: RemotePlanSimpleDescription]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "me/sites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: String] = [ + "fields": "ID, plan", + "locale": locale + ] + + wordPressComRESTAPI.get(path, + parameters: parameters as [String: AnyObject], + success: { + response, _ in + + guard let response = response as? EndpointResponse else { + failure(PlanServiceRemote.ResponseError.decodingFailure) + return + } + + let result = self.parsePlanDescriptionsForSites(response) + success(result) + }, + failure: { + error, _ in + failure(error) + }) + } + + // MARK: - Non-public methods + + func parsePlanDescriptionsForSites(_ response: EndpointResponse) -> [Int: RemotePlanSimpleDescription] { + var result = [Int: RemotePlanSimpleDescription]() + + guard let sites = response["sites"] as? [EndpointResponse] else { + return result + } + + for site in sites { + guard + let tpl = parsePlanDescriptionForSite(site) + else { + continue + } + result[tpl.siteID] = tpl.plan + } + + return result + } + + func parsePlanDescriptionForSite(_ site: EndpointResponse) -> (siteID: Int, plan: RemotePlanSimpleDescription)? { + guard + let siteID = site["ID"] as? Int, + let plan = site["plan"] as? EndpointResponse, + let planID = plan["product_id"] as? Int, + let planName = plan["product_name_short"] as? String, + let planSlug = plan["product_slug"] as? String else { + return nil + } + + var name = planName + if planSlug.contains("jetpack") { + name = name + " (Jetpack)" + } + + return (siteID, RemotePlanSimpleDescription(planID: planID, name: name)) + } + + func parseWpcomPlans(_ response: EndpointResponse) -> [RemoteWpcomPlan] { + guard let json = response["plans"] as? [EndpointResponse] else { + return [RemoteWpcomPlan]() + } + + return json.compactMap { parseWpcomPlan($0) } + } + + func parseWpcomPlanProducts(_ products: [EndpointResponse]) -> String { + let parsedResult = products.compactMap { $0["plan_id"] as? String } + return parsedResult.joined(separator: ",") + } + + func parseWpcomPlanGroups(_ response: EndpointResponse) -> [RemotePlanGroup] { + guard let json = response["groups"] as? [EndpointResponse] else { + return [RemotePlanGroup]() + } + return json.compactMap { parsePlanGroup($0) } + } + + func parseWpcomPlanFeatures(_ response: EndpointResponse) -> [RemotePlanFeature] { + guard let json = response["features"] as? [EndpointResponse] else { + return [RemotePlanFeature]() + } + return json.compactMap { parsePlanFeature($0) } + } + + func parseWpcomPlan(_ item: EndpointResponse) -> RemoteWpcomPlan? { + guard + let groups = (item["groups"] as? [String])?.joined(separator: ","), + let productsArray = item["products"] as? [EndpointResponse], + let name = item["name"] as? String, + let shortname = item["short_name"] as? String, + let tagline = item["tagline"] as? String, + let description = item["description"] as? String, + let features = (item["features"] as? [String])?.joined(separator: ","), + let icon = item["icon"] as? String, + let supportPriority = item["support_priority"] as? Int, + let supportName = item["support_name"] as? String, + let nonLocalizedShortname = item["nonlocalized_short_name"] as? String else { + return nil + } + + let products = parseWpcomPlanProducts(productsArray) + + return RemoteWpcomPlan(groups: groups, + products: products, + name: name, + shortname: shortname, + tagline: tagline, + description: description, + features: features, + icon: icon, + supportPriority: supportPriority, + supportName: supportName, + nonLocalizedShortname: nonLocalizedShortname) + } + + func parsePlanGroup(_ item: EndpointResponse) -> RemotePlanGroup? { + guard + let slug = item["slug"] as? String, + let name = item["name"] as? String else { + return nil + } + return RemotePlanGroup(slug: slug, name: name) + } + + func parsePlanFeature(_ item: EndpointResponse) -> RemotePlanFeature? { + guard + let slug = item["id"] as? String, + let title = item["name"] as? String, + let description = item["description"] as? String else { + return nil + } + return RemotePlanFeature(slug: slug, title: title, description: description, iconURL: nil) + } + + /// Retrieves Zendesk meta data: plan and Jetpack addons, if available + public func getZendeskMetadata(siteID: Int, completion: @escaping (Result) -> Void) { + let endpoint = "me/sites" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["fields": "ID, zendesk_site_meta"] as [String: AnyObject] + + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: path, parameters: parameters, type: ZendeskSiteContainer.self) + .eraseToError() + .flatMap { container in + guard let metadata = container.body.sites.filter({ $0.ID == siteID }).first?.zendeskMetadata else { + return .failure(PlanServiceRemoteError.noMetadata) + } + return .success(metadata) + } + .execute(completion) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote_ApiVersion1_3.swift b/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote_ApiVersion1_3.swift new file mode 100644 index 000000000000..b9fefa665ccb --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Plans/PlanServiceRemote_ApiVersion1_3.swift @@ -0,0 +1,63 @@ +import Foundation +import WordPressShared + +@objc public class PlanServiceRemote_ApiVersion1_3: ServiceRemoteWordPressComREST { + + public typealias SitePlans = (activePlan: RemotePlan_ApiVersion1_3, availablePlans: [RemotePlan_ApiVersion1_3]) + + public func getPlansForSite(_ siteID: Int, + success: @escaping (SitePlans) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plans" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_3) + + wordPressComRESTAPI.get( + path, + parameters: nil, + success: { response, _ in + do { + try success(PlanServiceRemote_ApiVersion1_3.mapPlansResponse(response)) + } catch { + WPKitLogError("Error parsing plans response for site \(siteID)") + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + failure(error) + } + }, + failure: { error, _ in + failure(error) + } + ) + } + + private static func mapPlansResponse(_ response: Any) throws + -> (activePlan: RemotePlan_ApiVersion1_3, availablePlans: [RemotePlan_ApiVersion1_3]) { + + guard let json = response as? [String: AnyObject] else { + throw PlanServiceRemote.ResponseError.decodingFailure + } + + var activePlans: [RemotePlan_ApiVersion1_3] = [] + var currentlyActivePlan: RemotePlan_ApiVersion1_3? + + try json.forEach { (key, value) in + let data = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) + do { + let decodedResult = try JSONDecoder.apiDecoder.decode(RemotePlan_ApiVersion1_3.self, from: data) + decodedResult.planID = key + activePlans.append(decodedResult) + if decodedResult.isCurrentPlan { + currentlyActivePlan = decodedResult + } + } catch let error { + WPKitLogError("Error parsing plans response for site \(error)") + } + } + + guard let activePlan = currentlyActivePlan else { + throw PlanServiceRemote.ResponseError.noActivePlan + } + return (activePlan, activePlans) + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Plugin Management/JetpackPluginManagementClient.swift b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/JetpackPluginManagementClient.swift new file mode 100644 index 000000000000..a0749eab314f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/JetpackPluginManagementClient.swift @@ -0,0 +1,46 @@ +public class JetpackPluginManagementClient: PluginManagementClient { + private let siteID: Int + private let remote: PluginServiceRemote + + public required init?(with siteID: Int, remote: PluginServiceRemote) { + self.siteID = siteID + self.remote = remote + } + + public func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + remote.getPlugins(siteID: siteID, success: success, failure: failure) + } + + public func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + remote.updatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.activatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.deactivatePlugin(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.enableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + + } + + public func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.disableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.activateAndEnableAutoupdates(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + remote.install(pluginSlug: pluginSlug, siteID: siteID, success: success, failure: failure) + } + + public func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + remote.remove(pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Plugin Management/PluginManagementClient.swift b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/PluginManagementClient.swift new file mode 100644 index 000000000000..a9d6d44b4645 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/PluginManagementClient.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol PluginManagementClient { + func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) + func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) + func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) + func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) + func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) +} diff --git a/WordPressKit/Sources/WordPressKit/Services/Plugin Management/SelfHostedPluginManagementClient.swift b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/SelfHostedPluginManagementClient.swift new file mode 100644 index 000000000000..cfb9e6328420 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/Plugin Management/SelfHostedPluginManagementClient.swift @@ -0,0 +1,162 @@ +public class SelfHostedPluginManagementClient: PluginManagementClient { + private let remote: WordPressOrgRestApi + + public required init?(with remote: WordPressOrgRestApi) { + self.remote = remote + } + + // MARK: - Get + public func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + Task { @MainActor in + await remote.get(path: path(), type: [PluginStateResponse].self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure + } + return error + } + .map { + SitePlugins( + plugins: $0.compactMap { self.pluginState(with: $0) }, + capabilities: SitePluginCapabilities(modify: true, autoupdate: false) + ) + } + .execute(onSuccess: success, onFailure: failure) + + } + } + + // MARK: - Activate / Deactivate + public func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["status": "active"] + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + + } + } + + public func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["status": "inactive"] + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + } + } + + // MARK: - Install / Uninstall + public func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + let parameters = ["slug": pluginSlug] + Task { @MainActor in + await remote.post(path: path(), parameters: parameters, type: PluginStateResponse.self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure + } + return error + } + .flatMap { + guard let state = self.pluginState(with: $0) else { + return .failure(PluginServiceRemote.ResponseError.decodingFailure) + } + return .success(state) + } + .execute(onSuccess: success, onFailure: failure) + } + } + + public func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let path = self.path(with: pluginID) + Task { @MainActor in + await remote.perform(.delete, path: path, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + } + } + + // MARK: - Private: Helpers + private func path(with slug: String? = nil) -> String { + var returnPath = "wp/v2/plugins/" + + if let slug = slug { + returnPath = returnPath.appending(slug) + } + + return returnPath + } + + private func pluginState(with response: PluginStateResponse) -> PluginState? { + guard + // The slugs returned are in the form of XXX/YYY + // The PluginStore uses slugs that are just XXX + // Extract that information out + let slug = response.plugin.components(separatedBy: "/").first + else { + return nil + } + + let isActive = response.status == "active" + + return PluginState(id: response.plugin, + slug: slug, + active: isActive, + name: response.name, + author: response.author, + version: response.version, + updateState: .updated, // API Doesn't support this yet + autoupdate: false, // API Doesn't support this yet + automanaged: false, // API Doesn't support this yet + // TODO: Return nil instead of an empty URL when 'plugin_uri' is nil? + url: URL(string: response.pluginURI ?? ""), + settingsURL: nil) + } + + // MARK: - Unsupported + public func updatePlugin(pluginID: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + } + + public func enableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + + success() + } + + public func disableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // NOOP - Not supported by the WP.org REST API + + success() + } + + public func activateAndEnableAutoupdates(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + // Just activate since API does not support autoupdates yet + activatePlugin(pluginID: pluginID, success: success, failure: failure) + } +} + +private struct PluginStateResponse: Decodable { + enum CodingKeys: String, CodingKey { + case plugin = "plugin" + case status = "status" + case name = "name" + case author = "author" + case version = "version" + case pluginURI = "plugin_uri" + } + var plugin: String + var status: String + var name: String + var author: String + var version: String + var pluginURI: String? +} + +private struct AnyResponse: Decodable { + init(from decoder: Decoder) throws { + // Do nothing + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PluginDirectoryServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/PluginDirectoryServiceRemote.swift new file mode 100644 index 000000000000..d46c3f194704 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PluginDirectoryServiceRemote.swift @@ -0,0 +1,146 @@ +import Foundation + +private struct PluginDirectoryRemoteConstants { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "YYYY-MM-dd h:mma z" + return formatter + }() + + static let jsonDecoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(PluginDirectoryRemoteConstants.dateFormatter) + return decoder + }() + + static let pluginsPerPage = 50 + + static let getInformationEndpoint = URL(string: "https://api.wordpress.org/plugins/info/1.0/")! + static let feedEndpoint = URL(string: "https://api.wordpress.org/plugins/info/1.1/")! + // note that this _isn't_ the same URL as PluginDirectoryGetInformationEndpoint. +} + +public enum PluginDirectoryFeedType: Hashable { + case popular + case newest + case search(term: String) + + public var slug: String { + switch self { + case .popular: + return "popular" + case .newest: + return "newest" + case .search(let term): + return "search:\(term)" + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(slug) + } + + public static func ==(lhs: PluginDirectoryFeedType, rhs: PluginDirectoryFeedType) -> Bool { + return lhs.slug == rhs.slug + } +} + +public struct PluginDirectoryGetInformationEndpoint { + public enum Error: Swift.Error { + case pluginNotFound + } + + let slug: String + public init(slug: String) { + self.slug = slug + } + + func buildRequest() throws -> URLRequest { + try HTTPRequestBuilder(url: PluginDirectoryRemoteConstants.getInformationEndpoint) + .appendURLString("\(slug).json") + .query(name: "fields", value: "icons,banners") + .build() + } + + func parseResponse(data: Data) throws -> PluginDirectoryEntry { + return try PluginDirectoryRemoteConstants.jsonDecoder.decode(PluginDirectoryEntry.self, from: data) + } + + func validate(response: HTTPURLResponse, data: Data?) throws { + // api.wordpress.org has an odd way of responding to plugin info requests for + // plugins not in the directory: it will return `null` with an HTTP 200 OK. + // This turns that case into a `.pluginNotFound` error. + if response.statusCode == 200, + let data = data, + data.count == 4, + String(data: data, encoding: .utf8) == "null" { + throw Error.pluginNotFound + } + } +} + +public struct PluginDirectoryFeedEndpoint { + public enum Error: Swift.Error { + case genericError + } + + let feedType: PluginDirectoryFeedType + let pageNumber: Int + + init(feedType: PluginDirectoryFeedType) { + self.feedType = feedType + self.pageNumber = 1 + } + + func buildRequest() throws -> URLRequest { + var parameters: [String: Any] = ["action": "query_plugins", + "request[per_page]": PluginDirectoryRemoteConstants.pluginsPerPage, + "request[fields][icons]": 1, + "request[fields][banners]": 1, + "request[fields][sections]": 0, + "request[page]": pageNumber] + switch feedType { + case .popular: + parameters["request[browse]"] = "popular" + case .newest: + parameters["request[browse]"] = "new" + case .search(let term): + parameters["request[search]"] = term + + } + + return try HTTPRequestBuilder(url: PluginDirectoryRemoteConstants.feedEndpoint) + .query(parameters) + .build() + } + + func parseResponse(data: Data) throws -> PluginDirectoryFeedPage { + return try PluginDirectoryRemoteConstants.jsonDecoder.decode(PluginDirectoryFeedPage.self, from: data) + } + + func validate(response: HTTPURLResponse, data: Data?) throws { + if response.statusCode != 200 { throw Error.genericError} + } +} + +public struct PluginDirectoryServiceRemote { + + public init() {} + + public func getPluginFeed(_ feedType: PluginDirectoryFeedType, pageNumber: Int = 1) async throws -> PluginDirectoryFeedPage { + let endpoint = PluginDirectoryFeedEndpoint(feedType: feedType) + let (data, response) = try await URLSession.shared.data(for: endpoint.buildRequest()) + let httpResponse = response as! HTTPURLResponse + try endpoint.validate(response: httpResponse, data: data) + return try endpoint.parseResponse(data: data) + } + + public func getPluginInformation(slug: String) async throws -> PluginDirectoryEntry { + let endpoint = PluginDirectoryGetInformationEndpoint(slug: slug) + let (data, response) = try await URLSession.shared.data(for: endpoint.buildRequest()) + let httpResponse = response as! HTTPURLResponse + try endpoint.validate(response: httpResponse, data: data) + return try endpoint.parseResponse(data: data) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PluginServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/PluginServiceRemote.swift new file mode 100644 index 000000000000..9661df9f43c4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PluginServiceRemote.swift @@ -0,0 +1,254 @@ +import Foundation + +public class PluginServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailure + case invalidInputError + case unauthorized + case unknownError + } + + public func getFeaturedPlugins(success: @escaping ([PluginDirectoryEntry]) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "wpcom/v2/plugins/featured" + + wordPressComRESTAPI.get(endpoint, parameters: nil, success: { (responseObject, _) in + guard let response = responseObject as? [[String: AnyObject]] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginEntries = try response.map { try PluginDirectoryEntry(responseObject: $0) } + success(pluginEntries) + } catch { + failure(ResponseError.decodingFailure) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error fetching featured plugins: \(error)") + failure(error) + }) + } + + public func getPlugins(siteID: Int, success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plugins" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + let parameters = [String: AnyObject]() + + wordPressComRESTAPI.get(path, parameters: parameters, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginStates = try self.pluginStates(response: response) + let capabilities = try self.pluginCapabilities(response: response) + success(SitePlugins(plugins: pluginStates, capabilities: capabilities)) + } catch { + failure(self.errorFromResponse(response)) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error fetching site plugins: \(error)") + failure(error) + }) + } + + public func updatePlugin(pluginID: String, siteID: Int, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)/update" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + let parameters = [String: AnyObject]() + + wordPressComRESTAPI.post( + path, + parameters: parameters, + success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginState = try self.pluginState(response: response) + success(pluginState) + } catch { + failure(self.errorFromResponse(response)) + } + }, + failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error updating plugin: \(error)") + failure(error) + }) + } + + public func activatePlugin(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func deactivatePlugin(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "false" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func enableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "autoupdate": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func disableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "autoupdate": "false" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func activateAndEnableAutoupdates(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let parameters = [ + "active": "true", + "autoupdate": "true" + ] as [String: AnyObject] + modifyPlugin(parameters: parameters, pluginID: pluginID, siteID: siteID, success: success, failure: failure) + } + + public func install(pluginSlug: String, siteID: Int, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { + let endpoint = "sites/\(siteID)/plugins/\(pluginSlug)/install" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: nil, + success: { responseObject, _ in + guard let response = responseObject as? [String: AnyObject] else { + failure(ResponseError.decodingFailure) + return + } + do { + let pluginState = try self.pluginState(response: response) + success(pluginState) + } catch { + failure(self.errorFromResponse(response)) + } + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error installing plugin: \(error)") + failure(error) + } + ) + } + + public func remove(pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: nil, + success: { _, _ in + success() + }, failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error removing plugin: \(error)") + failure(error) + } + ) + } + + private func modifyPlugin(parameters: [String: AnyObject], pluginID: String, siteID: Int, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + guard let escapedPluginID = encoded(pluginID: pluginID) else { + return + } + let endpoint = "sites/\(siteID)/plugins/\(escapedPluginID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + wordPressComRESTAPI.post( + path, + parameters: parameters, + success: { _, _ in + success() + }, + failure: { (error, _) in + WPKitLogError("[PluginServiceRemoteError] Error modifying plugin: \(error)") + failure(error) + }) + } +} + +internal extension PluginServiceRemote { + func encoded(pluginID: String) -> String? { + let allowedCharacters = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + guard let escapedPluginID = pluginID.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { + assertionFailure("Can't escape plugin ID: \(pluginID)") + return nil + } + return escapedPluginID + } + + func pluginStates(response: [String: AnyObject]) throws -> [PluginState] { + guard let plugins = response["plugins"] as? [[String: AnyObject]] else { + throw ResponseError.decodingFailure + } + + return try plugins.map { (plugin) -> PluginState in + return try pluginState(response: plugin) + } + } + + func pluginState(response: [String: AnyObject]) throws -> PluginState { + guard let id = response["name"] as? String, + let slug = response["slug"] as? String, + let active = response["active"] as? Bool, + let autoupdate = response["autoupdate"] as? Bool, + let name = response["display_name"] as? String, + let author = response["author"] as? String else { + throw ResponseError.decodingFailure + } + + let version = (response["version"] as? String)?.nonEmptyString() + let url = (response["plugin_url"] as? String).flatMap(URL.init(string:)) + let availableUpdate = (response["update"] as? [String: String])?["new_version"] + let updateState: PluginState.UpdateState = availableUpdate.map({ .available($0) }) ?? .updated + + let actions = response["action_links"] as? [String: String] + let settingsURL = (actions?["Settings"]).flatMap(URL.init(string:)) + + return PluginState(id: id, + slug: slug, + active: active, + name: name, + author: author, + version: version, + updateState: updateState, + autoupdate: autoupdate, + automanaged: false, + url: url, + settingsURL: settingsURL) + } + + func pluginCapabilities(response: [String: AnyObject]) throws -> SitePluginCapabilities { + guard let capabilities = response["file_mod_capabilities"] as? [String: AnyObject], + let modify = capabilities["modify_files"] as? Bool, + let autoupdate = capabilities["autoupdate_files"] as? Bool else { + throw ResponseError.decodingFailure + } + return SitePluginCapabilities(modify: modify, autoupdate: autoupdate) + } + + func errorFromResponse(_ response: [String: AnyObject]) -> ResponseError { + guard let code = response["error"] as? String else { + return .decodingFailure + } + switch code { + case "unauthorized": + return .unauthorized + default: + return .unknownError + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemote.h new file mode 100644 index 000000000000..e72bb490dd34 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemote.h @@ -0,0 +1,106 @@ +#import +#import + +@class RemotePost; +@class RemotePostUpdateParameters; + +@protocol PostServiceRemote + +/** + * @brief Requests the post with the specified ID. + * + * @param postID The ID of the post to get. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Requests the posts of the specified type. + * + * @param postType The type of the posts to get. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Requests the posts of the specified type using the specified options. + * + * @param postType The type of the posts to get. Cannot be nil. + * @param options The options to use for the request. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Creates a post remotely for the specified blog. + * + * @param post The post to create remotely. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Updates a blog's post. + * + * @param post The post to update. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Deletes a post. + * + * @param post The post to delete. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Trashes a post. + * + * @param post The post to trash. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure; + +/** + * @brief Restores a post. + * + * @param post The post to restore. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure; + +/** + * @brief Returns a dictionary set with option parameters of the PostServiceRemoteOptions protocol. + * + * @param options The object with set remote options. Cannot be nil. + */ +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteExtended.swift b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteExtended.swift new file mode 100644 index 000000000000..f5a6f1cb8d90 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteExtended.swift @@ -0,0 +1,31 @@ +import Foundation + +public protocol PostServiceRemoteExtended: PostServiceRemote { + /// Returns a post with the given ID. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func post(withID postID: Int) async throws -> RemotePost + + /// Creates a new post with the given parameters. + func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost + + /// Performs a partial update to the existing post. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost + + /// Permanently deletes a post with the given ID. + /// + /// - throws: ``PostServiceRemoteError`` or oher underlying errors + /// (see ``WordPressAPIError``) + func deletePost(withID postID: Int) async throws +} + +public enum PostServiceRemoteError: Error { + /// 409 (Conflict) + case conflict + /// 404 (Not Found) + case notFound +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteOptions.h b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteOptions.h new file mode 100644 index 000000000000..3e74a64ac10d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteOptions.h @@ -0,0 +1,86 @@ +#import + +typedef NS_ENUM(NSUInteger, PostServiceResultsOrder) { + /** + (default) Request results in descending order. For dates, that means newest to oldest. + */ + PostServiceResultsOrderDescending = 0, + /** + Request results in ascending order. For dates, that means oldest to newest. + */ + PostServiceResultsOrderAscending +}; + +typedef NS_ENUM(NSUInteger, PostServiceResultsOrdering) { + /** + (default) Order the results by the created time of each post. + */ + PostServiceResultsOrderingByDate = 0, + /** + Order the results by the modified time of each post. + */ + PostServiceResultsOrderingByModified, + /** + Order the results lexicographically by the title of each post. + */ + PostServiceResultsOrderingByTitle, + /** + Order the results by the number of comments for each pot. + */ + PostServiceResultsOrderingByCommentCount, + /** + Order the results by the postID of each post. + */ + PostServiceResultsOrderingByPostID +}; + +@protocol PostServiceRemoteOptions + +/** + List of PostStatuses for which to query + */ +- (NSArray *)statuses; + +/** + The number of posts to return. Limit: 100. + */ +- (NSNumber *)number; + +/** + 0-indexed offset for paging requests. + */ +- (NSNumber *)offset; + +/** + The order direction of the results. + */ +- (PostServiceResultsOrder)order; + +/** + The ordering value used when ordering results. + */ +- (PostServiceResultsOrdering)orderBy; + +/** + Specify posts only by the given authorID. + @attention Not supported in XML-RPC. + */ +- (NSNumber *)authorID; + +/** + A search query used when requesting posts. + */ +- (NSString *)search; + +/** + The metadata to include in the returned results. + */ +- (NSString *)meta; + +/** + The tag to filter by. + */ +@optional +- (NSString *)tag; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Extended.swift b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Extended.swift new file mode 100644 index 000000000000..315005463ebb --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Extended.swift @@ -0,0 +1,98 @@ +import Foundation + +extension PostServiceRemoteREST: PostServiceRemoteExtended { + public func post(withID postID: Int) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)?context=edit", withVersion: ._1_1) + let result = await wordPressComRestApi.perform(.get, URLString: path) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + if case .endpointError(let error) = error, error.apiErrorCode == "unknown_post" { + throw PostServiceRemoteError.notFound + } + throw error + } + } + + public func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/new?context=edit", withVersion: ._1_2) + let parameters = try makeParameters(from: RemotePostCreateParametersWordPressComEncoder(parameters: parameters)) + + let response = try await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters).get() + return try await decodePost(from: response.body) + } + + public func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)?context=edit", withVersion: ._1_2) + let parameters = try makeParameters(from: RemotePostUpdateParametersWordPressComEncoder(parameters: parameters)) + + let result = await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.apiErrorCode ?? "" { + case "unknown_post": throw PostServiceRemoteError.notFound + case "old-revision": throw PostServiceRemoteError.conflict + default: throw error + } + } + } + + public func deletePost(withID postID: Int) async throws { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/delete", withVersion: ._1_1) + let result = await wordPressComRestApi.perform(.post, URLString: path) + switch result { + case .success: + return + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.apiErrorCode ?? "" { + case "unknown_post": throw PostServiceRemoteError.notFound + default: throw error + } + } + } + + public func createAutosave(forPostID postID: Int, parameters: RemotePostCreateParameters) async throws -> RemotePostAutosaveResponse { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/autosave", withVersion: ._1_1) + let parameters = try makeParameters(from: RemotePostCreateParametersWordPressComEncoder(parameters: parameters)) + let result = await wordPressComRestApi.perform(.post, URLString: path, parameters: parameters, type: RemotePostAutosaveResponse.self) + return try result.get().body + } +} + +public struct RemotePostAutosaveResponse: Decodable { + public let autosaveID: Int + public let previewURL: URL + + enum CodingKeys: String, CodingKey { + case autosaveID = "ID" + case previewURL = "preview_URL" + } +} + +// Decodes the post in the background. +private func decodePost(from object: AnyObject) async throws -> RemotePost { + guard let dictionary = object as? [AnyHashable: Any] else { + throw WordPressAPIError.unparsableResponse(response: nil, body: nil) + } + return PostServiceRemoteREST.remotePost(fromJSONDictionary: dictionary) +} + +private func makeParameters(from value: T) throws -> [String: AnyObject] { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.wordPressCom) + let data = try encoder.encode(value) + let object = try JSONSerialization.jsonObject(with: data) + guard let dictionary = object as? [String: AnyObject] else { + throw URLError(.unknown) // This should never happen + } + return dictionary +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Revisions.swift b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Revisions.swift new file mode 100644 index 000000000000..0f9f61c06d63 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST+Revisions.swift @@ -0,0 +1,91 @@ +import Foundation + +public extension PostServiceRemoteREST { + func getPostRevisions(for siteId: Int, + postId: Int, + success: @escaping ([RemoteRevision]?) -> Void, + failure: @escaping (Error?) -> Void) { + let endpoint = "sites/\(siteId)/post/\(postId)/diffs" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + wordPressComRESTAPI.get(path, + parameters: nil, + success: { (response, _) in + do { + let data = try JSONSerialization.data(withJSONObject: response, options: []) + self.map(from: data) { (revisions, error) in + if let error = error { + failure(error) + } else { + success(revisions) + } + } + } catch { + failure(error) + } + }, failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + }) + } + + func getPostLatestRevisionID(for postId: NSNumber, success: @escaping (NSNumber?) -> Void, failure: @escaping (Error?) -> Void) { + let endpoint = "sites/\(siteID)/posts/\(postId)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + wordPressComRESTAPI.get( + path, + parameters: [ + "context": "edit", + "fields": "revisions" + ] as [String: AnyObject], + success: { (response, _) in + let latestRevision: NSNumber? + if let json = response as? [String: Any], + let revisions = json["revisions"] as? NSArray, + let latest = revisions.firstObject as? NSNumber { + latestRevision = latest + } else { + latestRevision = nil + } + success(latestRevision) + }, + failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + } + ) + } +} + +private extension PostServiceRemoteREST { + private typealias JSONRevision = [String: Any] + + private struct RemoteDiffs: Codable { + var diffs: [RemoteDiff] + } + + private func map(from data: Data, _ completion: @escaping ([RemoteRevision]?, Error?) -> Void) { + do { + var revisions: [RemoteRevision] = [] + + let diffs: RemoteDiffs = try decode(data) + let jsonResult = try JSONSerialization.jsonObject(with: data, options: .mutableLeaves) as? JSONRevision + let revisionsDict = jsonResult?["revisions"] as? [String: JSONRevision] + + try revisionsDict?.forEach { (key: String, value: JSONRevision) in + let revisionData = try JSONSerialization.data(withJSONObject: value, options: .prettyPrinted) + var revision: RemoteRevision = try decode(revisionData) + revision.diff = diffs.diffs.first { $0.toRevisionId == Int(key) } + revisions.append(revision) + } + completion(revisions, nil) + } catch { + WPKitLogError("\(error)") + completion(nil, error) + } + } + + private func decode(_ data: Data) throws -> T { + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.h new file mode 100644 index 000000000000..a278ea96d92a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.h @@ -0,0 +1,82 @@ +#import +#import +#import +#import + +@class RemoteUser; +@class RemoteLikeUser; + +@interface PostServiceRemoteREST : SiteServiceRemoteWordPressComREST + +/** + * @brief Create a post remotely for the specified blog with a single piece of + * media. + * + * @discussion This purpose of this method is to give app extensions the ability to create a post + * with media in a single network operation. + * + * @param post The post to create remotely. Cannot be nil. + * @param media The post to create remotely. Can be nil. + * @param requestEnqueued The block that will be executed when the network request is queued. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)createPost:(RemotePost * _Nonnull)post + withMedia:(RemoteMedia * _Nullable)media + requestEnqueued:(void (^ _Nullable)(NSNumber * _Nonnull taskID))requestEnqueued + success:(void (^ _Nullable)(RemotePost * _Nullable))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +/** + * @brief Saves a post. + * + * + * @discussion Drafts and auto-drafts are just overwritten by autosave for the same + user if the post is not locked. + * Non drafts or other users drafts are not overwritten. + * @param post The post to save. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)autoSave:(RemotePost * _Nonnull)post + success:(void (^ _Nullable)(RemotePost * _Nullable post, NSString * _Nullable previewURL))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + * @brief Get autosave revision of a post. + * + * + * @discussion retrieve the latest autosave revision of a post + + * @param post The post to save. Cannot be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getAutoSaveForPost:(RemotePost * _Nonnull)post + success:(void (^ _Nullable)(RemotePost * _Nullable))success + failure:(void (^ _Nullable)(NSError * _Nullable error))failure; + +/** + * @brief Requests a list of users that liked the post with the specified ID. + * + * @discussion Due to the API limitation, up to 90 users will be returned from the + * endpoint. + * + * @param postID The ID for the post. Cannot be nil. + * @param count Number of records to retrieve. Cannot be nil. If 0, will default to endpoint max. + * @param before Filter results to Likes before this date/time string. Can be nil. + * @param excludeUserIDs Array of user IDs to exclude from response. Can be nil. + * @param success The block that will be executed on success. Can be nil. + * @param failure The block that will be executed on failure. Can be nil. + */ +- (void)getLikesForPostID:(NSNumber * _Nonnull)postID + count:(NSNumber * _Nonnull)count + before:(NSString * _Nullable)before + excludeUserIDs:(NSArray * _Nullable)excludeUserIDs + success:(void (^ _Nullable)(NSArray * _Nonnull users, NSNumber * _Nonnull found))success + failure:(void (^ _Nullable)(NSError * _Nullable))failure; + +/// Returns a remote post with the given data. ++ (nonnull RemotePost *)remotePostFromJSONDictionary:(nonnull NSDictionary *)jsonPost; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.m new file mode 100644 index 000000000000..e4455a613602 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteREST.m @@ -0,0 +1,654 @@ +#import "PostServiceRemoteREST.h" +#import "RemotePost.h" +#import "RemotePostCategory.h" +#import "RemoteUser.h" +#import "WPKit-Swift.h" +@import WordPressShared; +@import NSObject_SafeExpectations; + +NSString * const PostRemoteStatusPublish = @"publish"; +NSString * const PostRemoteStatusScheduled = @"future"; + +static NSString * const RemoteOptionKeyNumber = @"number"; +static NSString * const RemoteOptionKeyOffset = @"offset"; +static NSString * const RemoteOptionKeyOrder = @"order"; +static NSString * const RemoteOptionKeyOrderBy = @"order_by"; +static NSString * const RemoteOptionKeyStatus = @"status"; +static NSString * const RemoteOptionKeySearch = @"search"; +static NSString * const RemoteOptionKeyAuthor = @"author"; +static NSString * const RemoteOptionKeyMeta = @"meta"; +static NSString * const RemoteOptionKeyTag = @"tag"; + +static NSString * const RemoteOptionValueOrderAscending = @"ASC"; +static NSString * const RemoteOptionValueOrderDescending = @"DESC"; +static NSString * const RemoteOptionValueOrderByDate = @"date"; +static NSString * const RemoteOptionValueOrderByModified = @"modified"; +static NSString * const RemoteOptionValueOrderByTitle = @"title"; +static NSString * const RemoteOptionValueOrderByCommentCount = @"comment_count"; +static NSString * const RemoteOptionValueOrderByPostID = @"ID"; + +@implementation PostServiceRemoteREST + +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(postID); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@", self.siteID, postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = @{ @"context": @"edit" }; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostFromJSONDictionary:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure +{ + [self getPostsOfType:postType options:nil success:success failure:failure]; +} + +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([postType isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ + @"status": @"any,trash", + @"context": @"edit", + @"number": @40, + @"type": postType, + }; + if (options) { + NSMutableDictionary *mutableParameters = [parameters mutableCopy]; + [mutableParameters addEntriesFromDictionary:options]; + parameters = [NSDictionary dictionaryWithDictionary:mutableParameters]; + } + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostsFromJSONArray:responseObject[@"posts"]]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +-(void)getAutoSaveForPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/autosave", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/new?context=edit", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + withMedia:(RemoteMedia *)media + requestEnqueued:(void (^)(NSNumber *taskID))requestEnqueued + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *type = media.mimeType; + NSString *filename = media.file; + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/new", self.siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{}]; + parameters[@"content"] = post.content; + parameters[@"title"] = post.title; + parameters[@"status"] = post.status; + FilePart *filePart = [[FilePart alloc] initWithParameterName:@"media[]" url:media.localURL fileName:filename mimeType:type]; + [self.wordPressComRESTAPI multipartPOST:requestUrl + parameters:parameters + fileParts:@[filePart] + requestEnqueued:^(NSNumber *taskID) { + if (requestEnqueued) { + requestEnqueued(taskID); + } + } success:^(id _Nonnull responseObject, NSHTTPURLResponse * _Nullable httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)autoSave:(RemotePost *)post + success:(void (^)(RemotePost *, NSString *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/autosave", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *parameters = [self parametersWithRemotePost:post]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + NSString *previewURL = responseObject[@"preview_URL"]; + if (success) { + success(post, previewURL); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/delete", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + // The parameters are passed as part of the string here because AlamoFire doesn't encode parameters on POST requests. + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/delete?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post isKindOfClass:[RemotePost class]]); + + // The parameters are passed as part of the string here because AlamoFire doesn't encode parameters on POST requests. + // https://github.com/wordpress-mobile/WordPressKit-iOS/pull/385 + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/restore?context=edit", self.siteID, post.postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + RemotePost *post = [self remotePostFromJSONDictionary:responseObject]; + if (success) { + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getLikesForPostID:(NSNumber *)postID + count:(NSNumber *)count + before:(NSString *)before + excludeUserIDs:(NSArray *)excludeUserIDs + success:(void (^)(NSArray * _Nonnull users, NSNumber *found))success + failure:(void (^)(NSError * _Nullable))failure +{ + NSParameterAssert(postID); + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/%@/likes", self.siteID, postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + NSNumber *siteID = self.siteID; + + // If no count provided, default to endpoint max. + if (count == 0) { + count = @90; + } + + NSMutableDictionary *parameters = [NSMutableDictionary dictionaryWithDictionary:@{ @"number": count }]; + + if (before) { + parameters[@"before"] = before; + } + + if (excludeUserIDs) { + parameters[@"exclude"] = excludeUserIDs; + } + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *jsonUsers = responseObject[@"likes"] ?: @[]; + NSArray *users = [self remoteUsersFromJSONArray:jsonUsers postID:postID siteID:siteID]; + NSNumber *found = [responseObject numberForKey:@"found"] ?: @0; + success(users, found); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options +{ + NSMutableDictionary *remoteParams = [NSMutableDictionary dictionary]; + if (options.number) { + [remoteParams setObject:options.number forKey:RemoteOptionKeyNumber]; + } + if (options.offset) { + [remoteParams setObject:options.offset forKey:RemoteOptionKeyOffset]; + } + + NSString *statusesStr = nil; + if (options.statuses.count) { + statusesStr = [options.statuses componentsJoinedByString:@","]; + } + if (options.order) { + NSString *orderStr = nil; + switch (options.order) { + case PostServiceResultsOrderDescending: + orderStr = RemoteOptionValueOrderDescending; + break; + case PostServiceResultsOrderAscending: + orderStr = RemoteOptionValueOrderAscending; + break; + } + [remoteParams setObject:orderStr forKey:RemoteOptionKeyOrder]; + } + + NSString *orderByStr = nil; + if (options.orderBy) { + switch (options.orderBy) { + case PostServiceResultsOrderingByDate: + orderByStr = RemoteOptionValueOrderByDate; + break; + case PostServiceResultsOrderingByModified: + orderByStr = RemoteOptionValueOrderByModified; + break; + case PostServiceResultsOrderingByTitle: + orderByStr = RemoteOptionValueOrderByTitle; + break; + case PostServiceResultsOrderingByCommentCount: + orderByStr = RemoteOptionValueOrderByCommentCount; + break; + case PostServiceResultsOrderingByPostID: + orderByStr = RemoteOptionValueOrderByPostID; + break; + } + } + + if (statusesStr.length) { + [remoteParams setObject:statusesStr forKey:RemoteOptionKeyStatus]; + } + if (orderByStr.length) { + [remoteParams setObject:orderByStr forKey:RemoteOptionKeyOrderBy]; + } + if (options.authorID) { + [remoteParams setObject:options.authorID forKey:RemoteOptionKeyAuthor]; + } + if (options.search.length > 0) { + [remoteParams setObject:options.search forKey:RemoteOptionKeySearch]; + } + if (options.meta.length > 0) { + [remoteParams setObject:options.meta forKey:RemoteOptionKeyMeta]; + } + if ([options respondsToSelector:@selector(tag)] && options.tag.length > 0) { + [remoteParams setObject:options.tag forKey:RemoteOptionKeyTag]; + } + + return remoteParams.count ? [NSDictionary dictionaryWithDictionary:remoteParams] : nil; +} + +#pragma mark - Private methods + +- (NSArray *)remotePostsFromJSONArray:(NSArray *)jsonPosts { + return [jsonPosts wp_map:^id(NSDictionary *jsonPost) { + return [self remotePostFromJSONDictionary:jsonPost]; + }]; +} + +- (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost { + return [PostServiceRemoteREST remotePostFromJSONDictionary:jsonPost]; +} + ++ (RemotePost *)remotePostFromJSONDictionary:(NSDictionary *)jsonPost { + RemotePost *post = [RemotePost new]; + post.postID = jsonPost[@"ID"]; + post.siteID = jsonPost[@"site_ID"]; + if (jsonPost[@"author"] != [NSNull null]) { + NSDictionary *authorDictionary = jsonPost[@"author"]; + post.authorAvatarURL = authorDictionary[@"avatar_URL"]; + post.authorDisplayName = authorDictionary[@"name"]; + post.authorEmail = [authorDictionary stringForKey:@"email"]; + post.authorURL = authorDictionary[@"URL"]; + } + post.authorID = [jsonPost numberForKeyPath:@"author.ID"]; + post.date = [NSDate dateWithWordPressComJSONString:jsonPost[@"date"]]; + post.dateModified = [NSDate dateWithWordPressComJSONString:jsonPost[@"modified"]]; + post.title = jsonPost[@"title"]; + post.URL = [NSURL URLWithString:jsonPost[@"URL"]]; + post.shortURL = [NSURL URLWithString:jsonPost[@"short_URL"]]; + post.content = jsonPost[@"content"]; + post.excerpt = jsonPost[@"excerpt"]; + post.slug = jsonPost[@"slug"]; + post.suggestedSlug = [jsonPost stringForKeyPath:@"other_URLs.suggested_slug"]; + post.status = jsonPost[@"status"]; + post.password = jsonPost[@"password"]; + if ([post.password isEmpty]) { + post.password = nil; + } + post.parentID = [jsonPost numberForKeyPath:@"parent.ID"]; + // post_thumbnail can be null, which will transform to NSNull, so we need to add the extra check + NSDictionary *postThumbnail = [jsonPost dictionaryForKey:@"post_thumbnail"]; + post.postThumbnailID = [postThumbnail numberForKey:@"ID"]; + post.postThumbnailPath = [postThumbnail stringForKeyPath:@"URL"]; + post.type = jsonPost[@"type"]; + post.format = jsonPost[@"format"]; + + post.commentCount = [jsonPost numberForKeyPath:@"discussion.comment_count"] ?: @0; + post.likeCount = [jsonPost numberForKeyPath:@"like_count"] ?: @0; + + post.isStickyPost = [jsonPost numberForKeyPath:@"sticky"]; + + // FIXME: remove conversion once API is fixed #38-io + // metadata should always be an array but it's returning false when there are no custom fields + post.metadata = [jsonPost arrayForKey:@"metadata"]; + // Or even worse, in some cases (Jetpack sites?) is an array containing false + post.metadata = [post.metadata filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id evaluatedObject, NSDictionary *bindings) { + return [evaluatedObject isKindOfClass:[NSDictionary class]]; + }]]; + // post.metadata = jsonPost[@"metadata"]; + + NSDictionary *categories = jsonPost[@"categories"]; + if (categories) { + post.categories = [self remoteCategoriesFromJSONArray:[categories allValues]]; + } + post.tags = [self tagNamesFromJSONDictionary:jsonPost[@"tags"]]; + + post.revisions = [jsonPost arrayForKey:@"revisions"]; + + NSDictionary *autosaveAttributes = jsonPost[@"meta"][@"data"][@"autosave"]; + if ([autosaveAttributes wp_isValidObject]) { + RemotePostAutosave *autosave = [[RemotePostAutosave alloc] init]; + autosave.title = autosaveAttributes[@"title"]; + autosave.content = autosaveAttributes[@"content"]; + autosave.excerpt = autosaveAttributes[@"excerpt"]; + autosave.modifiedDate = [NSDate dateWithWordPressComJSONString:autosaveAttributes[@"modified"]]; + autosave.identifier = autosaveAttributes[@"ID"]; + autosave.authorID = autosaveAttributes[@"author_ID"]; + autosave.postID = autosaveAttributes[@"post_ID"]; + autosave.previewURL = autosaveAttributes[@"preview_URL"]; + post.autosave = autosave; + } + + // Pick an image to use for display + if (post.postThumbnailPath) { + post.pathForDisplayImage = post.postThumbnailPath; + } else { + // parse contents for a suitable image + post.pathForDisplayImage = [DisplayableImageHelper searchPostContentForImageToDisplay:post.content]; + if ([post.pathForDisplayImage length] == 0) { + post.pathForDisplayImage = [DisplayableImageHelper searchPostAttachmentsForImageToDisplay:[jsonPost dictionaryForKey:@"attachments"] existingInContent:post.content]; + } + } + + return post; +} + +- (NSDictionary *)parametersWithRemotePost:(RemotePost *)post +{ + NSParameterAssert(post.title != nil); + NSParameterAssert(post.content != nil); + BOOL existingPost = ([post.postID longLongValue] > 0); + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + + if (post.title) { + parameters[@"title"] = post.title; + } else { + parameters[@"title"] = @""; + } + + parameters[@"content"] = post.content; + parameters[@"password"] = post.password ? post.password : @""; + parameters[@"type"] = post.type; + + if (post.date) { + parameters[@"date"] = [post.date WordPressComJSONString]; + } else if (existingPost) { + // safety net. An existing post with no create date should publish immediately + parameters[@"date"] = [[NSDate date] WordPressComJSONString]; + } + if (post.excerpt) { + parameters[@"excerpt"] = post.excerpt; + } + if (post.slug) { + parameters[@"slug"] = post.slug; + } + + if (post.authorID) { + parameters[@"author"] = post.authorID; + } + + if (post.categories) { + parameters[@"categories_by_id"] = [post.categories valueForKey:@"categoryID"]; + } + + if (post.tags) { + NSArray *tags = post.tags; + NSDictionary *postTags = @{@"post_tag":tags}; + parameters[@"terms"] = postTags; + } + if (post.format) { + parameters[@"format"] = post.format; + } + + parameters[@"parent"] = post.parentID ?: @"false"; + parameters[@"featured_image"] = post.postThumbnailID ? [post.postThumbnailID stringValue] : @""; + + NSArray *metadata = [self metadataForPost:post]; + if (metadata.count > 0) { + parameters[@"metadata"] = metadata; + } + + if (post.isStickyPost != nil) { + parameters[@"sticky"] = post.isStickyPost.boolValue ? @"true" : @"false"; + } + + // Scheduled posts need to sync with a status of 'publish'. + // Passing a status of 'future' will set the post status to 'draft' + // This is an apparent inconsistency in the API as 'future' should + // be a valid status. + if ([post.status isEqualToString:PostRemoteStatusScheduled]) { + post.status = PostRemoteStatusPublish; + } + parameters[@"status"] = post.status; + + // Test what happens for nil and not present values + return [NSDictionary dictionaryWithDictionary:parameters]; +} + +- (NSArray *)metadataForPost:(RemotePost *)post { + return [post.metadata wp_map:^id(NSDictionary *meta) { + NSNumber *metaID = [meta objectForKey:@"id"]; + NSString *metaValue = [meta objectForKey:@"value"]; + NSString *metaKey = [meta objectForKey:@"key"]; + NSString *operation = @"update"; + + if (!metaKey) { + if (metaID && !metaValue) { + operation = @"delete"; + } else if (!metaID && metaValue) { + operation = @"add"; + } + } + + NSMutableDictionary *modifiedMeta = [meta mutableCopy]; + modifiedMeta[@"operation"] = operation; + return [NSDictionary dictionaryWithDictionary:modifiedMeta]; + }]; +} + ++ (NSArray *)remoteCategoriesFromJSONArray:(NSArray *)jsonCategories { + return [jsonCategories wp_map:^id(NSDictionary *jsonCategory) { + return [self remoteCategoryFromJSONDictionary:jsonCategory]; + }]; +} + ++ (RemotePostCategory *)remoteCategoryFromJSONDictionary:(NSDictionary *)jsonCategory { + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = jsonCategory[@"ID"]; + category.name = jsonCategory[@"name"]; + category.parentID = jsonCategory[@"parent"]; + + return category; +} + ++ (NSArray *)tagNamesFromJSONDictionary:(NSDictionary *)jsonTags { + return [jsonTags allKeys]; +} + +/** + * @brief Returns an array of RemoteLikeUser based on provided JSON + * representation of users. + * + * @param jsonUsers An array containing JSON representations of users. + * @param postID ID of the Post the users liked. + * @param siteID ID of the Post's site. + */ +- (NSArray *)remoteUsersFromJSONArray:(NSArray *)jsonUsers + postID:(NSNumber *)postID + siteID:(NSNumber *)siteID +{ + return [jsonUsers wp_map:^id(NSDictionary *jsonUser) { + return [[RemoteLikeUser alloc] initWithDictionary:jsonUser postID:postID siteID:siteID]; + }]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC+Extended.swift b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC+Extended.swift new file mode 100644 index 000000000000..904941fda404 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC+Extended.swift @@ -0,0 +1,79 @@ +import Foundation +import wpxmlrpc + +extension PostServiceRemoteXMLRPC: PostServiceRemoteExtended { + public func post(withID postID: Int) async throws -> RemotePost { + let parameters = xmlrpcArguments(withExtra: postID) as [AnyObject] + let result = await api.call(method: "wp.getPost", parameters: parameters) + switch result { + case .success(let response): + return try await decodePost(from: response.body) + case .failure(let error): + if case .endpointError(let error) = error, error.code == 404 { + throw PostServiceRemoteError.notFound + } + throw error + } + } + + public func createPost(with parameters: RemotePostCreateParameters) async throws -> RemotePost { + let dictionary = try makeParameters(from: RemotePostCreateParametersXMLRPCEncoder(parameters: parameters)) + let parameters = xmlrpcArguments(withExtra: dictionary) as [AnyObject] + let response = try await api.call(method: "wp.newPost", parameters: parameters).get() + guard let postID = (response.body as? NSObject)?.numericValue() else { + throw URLError(.unknown) // Should never happen + } + return try await post(withID: postID.intValue) + } + + public func patchPost(withID postID: Int, parameters: RemotePostUpdateParameters) async throws -> RemotePost { + let dictionary = try makeParameters(from: RemotePostUpdateParametersXMLRPCEncoder(parameters: parameters)) + let parameters = xmlrpcArguments(withExtraDefaults: [postID as NSNumber], andExtra: dictionary) as [AnyObject] + let result = await api.call(method: "wp.editPost", parameters: parameters) + switch result { + case .success: + return try await post(withID: postID) + case .failure(let error): + guard case .endpointError(let error) = error else { + throw error + } + switch error.code ?? 0 { + case 404: throw PostServiceRemoteError.notFound + case 409: throw PostServiceRemoteError.conflict + default: throw error + } + } + } + + public func deletePost(withID postID: Int) async throws { + let parameters = xmlrpcArguments(withExtra: postID) as [AnyObject] + let result = await api.call(method: "wp.deletePost", parameters: parameters) + switch result { + case .success: + return + case .failure(let error): + if case .endpointError(let error) = error, error.code == 404 { + throw PostServiceRemoteError.notFound + } + throw error + } + } +} + +private func decodePost(from object: AnyObject) async throws -> RemotePost { + guard let dictionary = object as? [AnyHashable: Any] else { + throw WordPressAPIError.unparsableResponse(response: nil, body: nil) + } + return PostServiceRemoteXMLRPC.remotePost(fromXMLRPCDictionary: dictionary) +} + +private func makeParameters(from value: T) throws -> [String: AnyObject] { + let encoder = PropertyListEncoder() + encoder.outputFormat = .xml + let data = try encoder.encode(value) + let object = try PropertyListSerialization.propertyList(from: data, format: nil) + guard let dictionary = object as? [String: AnyObject] else { + throw URLError(.unknown) // This should never happen + } + return dictionary +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..897863850ecc --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.h @@ -0,0 +1,9 @@ +#import +#import +#import + +@interface PostServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + ++ (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..e5454bc824a1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PostServiceRemoteXMLRPC.m @@ -0,0 +1,459 @@ +#import "PostServiceRemoteXMLRPC.h" +#import "RemotePost.h" +#import "RemotePostCategory.h" +#import "NSMutableDictionary+Helpers.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +const NSInteger HTTP404ErrorCode = 404; +NSString * const WordPressAppErrorDomain = @"org.wordpress.iphone"; + +static NSString * const RemoteOptionKeyNumber = @"number"; +static NSString * const RemoteOptionKeyOffset = @"offset"; +static NSString * const RemoteOptionKeyOrder = @"order"; +static NSString * const RemoteOptionKeyOrderBy = @"orderby"; +static NSString * const RemoteOptionKeyStatus = @"post_status"; +static NSString * const RemoteOptionKeySearch = @"s"; + +static NSString * const RemoteOptionValueOrderAscending = @"ASC"; +static NSString * const RemoteOptionValueOrderDescending = @"DESC"; +static NSString * const RemoteOptionValueOrderByDate = @"date"; +static NSString * const RemoteOptionValueOrderByModified = @"modified"; +static NSString * const RemoteOptionValueOrderByTitle = @"title"; +static NSString * const RemoteOptionValueOrderByCommentCount = @"comment_count"; +static NSString * const RemoteOptionValueOrderByPostID = @"ID"; + +@implementation PostServiceRemoteXMLRPC + +- (void)getPostWithID:(NSNumber *)postID + success:(void (^)(RemotePost *post))success + failure:(void (^)(NSError *))failure +{ + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + [self.api callMethod:@"wp.getPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success([self remotePostFromXMLRPCDictionary:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getPostsOfType:(NSString *)postType + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *))failure { + [self getPostsOfType:postType options:nil success:success failure:failure]; +} + +- (void)getPostsOfType:(NSString *)postType + options:(NSDictionary *)options + success:(void (^)(NSArray *remotePosts))success + failure:(void (^)(NSError *error))failure { + NSArray *statuses = @[PostStatusDraft, PostStatusPending, PostStatusPrivate, PostStatusPublish, PostStatusScheduled, PostStatusTrash]; + NSString *postStatus = [statuses componentsJoinedByString:@","]; + NSDictionary *extraParameters = @{ + @"number": @40, + @"post_type": postType, + @"post_status": postStatus, + }; + if (options) { + NSMutableDictionary *mutableParameters = [extraParameters mutableCopy]; + [mutableParameters addEntriesFromDictionary:options]; + extraParameters = [NSDictionary dictionaryWithDictionary:mutableParameters]; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"wp.getPosts" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSAssert([responseObject isKindOfClass:[NSArray class]], @"Response should be an array."); + if (success) { + success([self remotePostsFromXMLRPCArray:responseObject]); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)createPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSDictionary *extraParameters = [self parametersWithRemotePost:post]; + NSArray *parameters = [self XMLRPCArgumentsWithExtra:extraParameters]; + [self.api callMethod:@"metaWeblog.newPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if ([responseObject respondsToSelector:@selector(numericValue)]) { + post.postID = [responseObject numericValue]; + + if (!post.date) { + // Set the temporary date until we get it from the server so it sorts properly on the list + post.date = [NSDate date]; + } + + [self getPostWithID:post.postID success:^(RemotePost *fetchedPost) { + if (success) { + success(fetchedPost); + } + } failure:^(NSError *error) { + // update failed, and that sucks, but creating the post succeeded… so, let's just act like everything is ok! + if (success) { + success(post); + } + }]; + } else if (failure) { + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Invalid value returned for new post: %@", responseObject]}; + NSError *error = [NSError errorWithDomain:WordPressAppErrorDomain code:0 userInfo:userInfo]; + failure(error); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updatePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert(post.postID.integerValue > 0); + + if ([post.postID integerValue] <= 0) { + if (failure) { + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: @"Can't edit a post if it's not in the server"}; + NSError *error = [NSError errorWithDomain:WordPressAppErrorDomain code:0 userInfo:userInfo]; + dispatch_async(dispatch_get_main_queue(), ^{ + failure(error); + }); + } + return; + } + + NSDictionary *extraParameters = [self parametersWithRemotePost:post]; + NSMutableArray *parameters = [NSMutableArray arrayWithArray:[self XMLRPCArgumentsWithExtra:extraParameters]]; + [parameters replaceObjectAtIndex:0 withObject:post.postID]; + [self.api callMethod:@"metaWeblog.editPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + [self getPostWithID:post.postID success:^(RemotePost *fetchedPost) { + if (success) { + success(fetchedPost); + } + } failure:^(NSError *error) { + //We failed to fetch the post but the update was successful + if (success) { + success(post); + } + }]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deletePost:(RemotePost *)post + success:(void (^)(void))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post.postID longLongValue] > 0); + NSNumber *postID = post.postID; + if ([postID longLongValue] > 0) { + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + [self.api callMethod:@"wp.deletePost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) success(); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) failure(error); + }]; + } +} + +- (void)trashPost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *))failure +{ + NSParameterAssert([post.postID longLongValue] > 0); + NSNumber *postID = post.postID; + if ([postID longLongValue] <= 0) { + return; + } + NSArray *parameters = [self XMLRPCArgumentsWithExtra:postID]; + + [self.api callMethod:@"wp.deletePost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + [self.api callMethod:@"wp.getPost" + parameters:parameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + // The post was trashed but not yet deleted. + RemotePost *post = [self remotePostFromXMLRPCDictionary:responseObject]; + success(post); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (httpResponse.statusCode == HTTP404ErrorCode) { + // The post was deleted. + if (success) { + success(post); + } + } + }]; + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)restorePost:(RemotePost *)post + success:(void (^)(RemotePost *))success + failure:(void (^)(NSError *error))failure +{ + [self updatePost:post success:success failure:failure]; +} + +- (NSDictionary *)dictionaryWithRemoteOptions:(id )options +{ + NSMutableDictionary *remoteParams = [NSMutableDictionary dictionary]; + if (options.number) { + [remoteParams setObject:options.number forKey:RemoteOptionKeyNumber]; + } + if (options.offset) { + [remoteParams setObject:options.offset forKey:RemoteOptionKeyOffset]; + } + + NSString *statusesStr = nil; + if (options.statuses.count) { + statusesStr = [options.statuses componentsJoinedByString:@","]; + } + if (options.order) { + NSString *orderStr = nil; + switch (options.order) { + case PostServiceResultsOrderDescending: + orderStr = RemoteOptionValueOrderDescending; + break; + case PostServiceResultsOrderAscending: + orderStr = RemoteOptionValueOrderAscending; + break; + } + [remoteParams setObject:orderStr forKey:RemoteOptionKeyOrder]; + } + + NSString *orderByStr = nil; + if (options.orderBy) { + switch (options.orderBy) { + case PostServiceResultsOrderingByDate: + orderByStr = RemoteOptionValueOrderByDate; + break; + case PostServiceResultsOrderingByModified: + orderByStr = RemoteOptionValueOrderByModified; + break; + case PostServiceResultsOrderingByTitle: + orderByStr = RemoteOptionValueOrderByTitle; + break; + case PostServiceResultsOrderingByCommentCount: + orderByStr = RemoteOptionValueOrderByCommentCount; + break; + case PostServiceResultsOrderingByPostID: + orderByStr = RemoteOptionValueOrderByPostID; + break; + } + } + + if (statusesStr.length) { + [remoteParams setObject:statusesStr forKey:RemoteOptionKeyStatus]; + } + if (orderByStr.length) { + [remoteParams setObject:orderByStr forKey:RemoteOptionKeyOrderBy]; + } + + NSString *search = [options search]; + if (search.length) { + [remoteParams setObject:search forKey:RemoteOptionKeySearch]; + } + + return remoteParams.count ? [NSDictionary dictionaryWithDictionary:remoteParams] : nil; +} + +#pragma mark - Private methods + +- (NSArray *)remotePostsFromXMLRPCArray:(NSArray *)xmlrpcArray { + return [xmlrpcArray wp_map:^id(NSDictionary *xmlrpcPost) { + return [self remotePostFromXMLRPCDictionary:xmlrpcPost]; + }]; +} + +- (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary { + return [PostServiceRemoteXMLRPC remotePostFromXMLRPCDictionary:xmlrpcDictionary]; +} + ++ (RemotePost *)remotePostFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary { + RemotePost *post = [RemotePost new]; + + post.postID = [xmlrpcDictionary numberForKey:@"post_id"]; + post.date = xmlrpcDictionary[@"post_date_gmt"]; + post.dateModified = xmlrpcDictionary[@"post_modified_gmt"]; + if (xmlrpcDictionary[@"link"]) { + post.URL = [NSURL URLWithString:xmlrpcDictionary[@"link"]]; + } + post.title = xmlrpcDictionary[@"post_title"]; + post.content = xmlrpcDictionary[@"post_content"]; + post.excerpt = xmlrpcDictionary[@"post_excerpt"]; + post.slug = xmlrpcDictionary[@"post_name"]; + post.authorID = [xmlrpcDictionary numberForKey:@"post_author"]; + post.status = [self statusForPostStatus:xmlrpcDictionary[@"post_status"] andDate:post.date]; + post.password = xmlrpcDictionary[@"post_password"]; + if ([post.password isEmpty]) { + post.password = nil; + } + post.parentID = [xmlrpcDictionary numberForKey:@"post_parent"]; + // When there is no featured image, post_thumbnail is an empty array :( + NSDictionary *thumbnailDict = [xmlrpcDictionary dictionaryForKey:@"post_thumbnail"]; + post.postThumbnailID = [thumbnailDict numberForKey:@"attachment_id"]; + post.postThumbnailPath = [thumbnailDict stringForKey:@"link"]; + post.type = xmlrpcDictionary[@"post_type"]; + post.format = xmlrpcDictionary[@"post_format"]; + + post.metadata = xmlrpcDictionary[@"custom_fields"]; + + NSArray *terms = [xmlrpcDictionary arrayForKey:@"terms"]; + post.tags = [self tagsFromXMLRPCTermsArray:terms]; + post.categories = [self remoteCategoriesFromXMLRPCTermsArray:terms]; + + post.isStickyPost = [xmlrpcDictionary numberForKeyPath:@"sticky"]; + + // Pick an image to use for display + if (post.postThumbnailPath) { + post.pathForDisplayImage = post.postThumbnailPath; + } else { + // parse content for a suitable image. + post.pathForDisplayImage = [DisplayableImageHelper searchPostContentForImageToDisplay:post.content]; + } + + return post; +} + ++ (NSString *)statusForPostStatus:(NSString *)status andDate:(NSDate *)date +{ + // Scheduled posts are synced with a post_status of 'publish' but we want to + // work with a status of 'future' from within the app. + if ([status isEqualToString:PostStatusPublish] && date == [date laterDate:[NSDate date]]) { + return PostStatusScheduled; + } + return status; +} + ++ (NSArray *)tagsFromXMLRPCTermsArray:(NSArray *)terms { + NSArray *tags = [terms filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"taxonomy = 'post_tag' AND name != NIL"]]; + return [tags valueForKey:@"name"]; +} + ++ (NSArray *)remoteCategoriesFromXMLRPCTermsArray:(NSArray *)terms { + return [[terms wp_filter:^BOOL(NSDictionary *category) { + return [[category stringForKey:@"taxonomy"] isEqualToString:@"category"]; + }] wp_map:^id(NSDictionary *category) { + return [self remoteCategoryFromXMLRPCDictionary:category]; + }]; +} + ++ (RemotePostCategory *)remoteCategoryFromXMLRPCDictionary:(NSDictionary *)xmlrpcCategory { + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [xmlrpcCategory numberForKey:@"term_id"]; + category.name = [xmlrpcCategory stringForKey:@"name"]; + category.parentID = [xmlrpcCategory numberForKey:@"parent"]; + return category; +} + +- (NSDictionary *)parametersWithRemotePost:(RemotePost *)post +{ + BOOL existingPost = ([post.postID longLongValue] > 0); + NSMutableDictionary *postParams = [NSMutableDictionary dictionary]; + + [postParams setValueIfNotNil:post.type forKey:@"post_type"]; + [postParams setValueIfNotNil:post.title forKey:@"title"]; + [postParams setValueIfNotNil:post.content forKey:@"description"]; + [postParams setValueIfNotNil:post.date forKey:@"date_created_gmt"]; + [postParams setValueIfNotNil:post.password forKey:@"wp_password"]; + [postParams setValueIfNotNil:[post.URL absoluteString] forKey:@"permalink"]; + [postParams setValueIfNotNil:post.excerpt forKey:@"mt_excerpt"]; + [postParams setValueIfNotNil:post.slug forKey:@"wp_slug"]; + [postParams setValueIfNotNil:post.authorID forKey:@"wp_author_id"]; + + // To remove a featured image, you have to send an empty string to the API + if (post.postThumbnailID == nil) { + // Including an empty string for wp_post_thumbnail generates + // an "Invalid attachment ID" error in the call to wp.newPage + if (existingPost) { + postParams[@"wp_post_thumbnail"] = @""; + } + } else if (!existingPost || post.isFeaturedImageChanged) { + // Do not add this param to existing posts when the featured image has not changed. + // Doing so results in a XML-RPC fault: Invalid attachment ID. + postParams[@"wp_post_thumbnail"] = post.postThumbnailID; + } + + [postParams setValueIfNotNil:post.format forKey:@"wp_post_format"]; + [postParams setValueIfNotNil:[post.tags componentsJoinedByString:@","] forKey:@"mt_keywords"]; + + if (existingPost && post.date == nil) { + // Change the date of an already published post to the current date/time. (publish immediately) + // Pass the current date so the post is updated correctly + postParams[@"date_created_gmt"] = [NSDate date]; + } + if (post.categories) { + NSArray *categoryNames = [post.categories wp_map:^id(RemotePostCategory *category) { + return category.name; + }]; + + postParams[@"categories"] = categoryNames; + } + + if ([post.metadata count] > 0) { + postParams[@"custom_fields"] = post.metadata; + } + + postParams[@"wp_page_parent_id"] = post.parentID ? post.parentID.stringValue : @"0"; + + // Scheduled posts need to sync with a status of 'publish'. + // Passing a status of 'future' will set the post status to 'draft' + // This is an apparent inconsistency in the XML-RPC API as 'future' should + // be a valid status. + // https://codex.wordpress.org/Post_Status_Transitions + if (post.status == nil || [post.status isEqualToString:PostStatusScheduled]) { + post.status = PostStatusPublish; + } + + // At least as of 5.2.2, Private and/or Password Protected posts can't be stickied. + // However, the code used on the backend doesn't check the value of the `sticky` field, + // instead doing a simple `! empty( $post_data['sticky'] )` check. + // + // This means we have to omit this field entirely for those posts from the payload we're sending + // to the XML-RPC sevices. + // + // https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-xmlrpc-server.php + // + BOOL shouldIncludeStickyField = ![post.status isEqualToString:PostStatusPrivate] && post.password == nil; + + if (post.isStickyPost != nil && shouldIncludeStickyField) { + postParams[@"sticky"] = post.isStickyPost.boolValue ? @"true" : @"false"; + } + + if ([post.type isEqualToString:@"page"]) { + [postParams setObject:post.status forKey:@"page_status"]; + } + [postParams setObject:post.status forKey:@"post_status"]; + + return [NSDictionary dictionaryWithDictionary:postParams]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ProductServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/ProductServiceRemote.swift new file mode 100644 index 000000000000..cb9545fa134e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ProductServiceRemote.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Provides information about available products for user purchases, such as plans, domains, etc. +/// +open class ProductServiceRemote { + public struct Product { + public let id: Int + public let key: String + public let name: String + public let slug: String + public let description: String + public let currencyCode: String? + public let saleCost: Double? + + public func saleCostForDisplay() -> String? { + guard let currencyCode = currencyCode, + let saleCost = saleCost else { + return nil + } + + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .currency + numberFormatter.currencyCode = currencyCode + + return numberFormatter.string(from: NSNumber(value: saleCost)) + } + } + + let serviceRemote: ServiceRemoteWordPressComREST + + public enum GetProductError: Error { + case failedCastingProductsToDictionary(Any) + } + + public init(restAPI: WordPressComRestApi) { + serviceRemote = ServiceRemoteWordPressComREST(wordPressComRestApi: restAPI) + } + + /// Gets a list of available products for purchase. + /// + open func getProducts(completion: @escaping (Result<[Product], Error>) -> Void) { + let path = serviceRemote.path(forEndpoint: "products", withVersion: ._1_1) + + serviceRemote.wordPressComRESTAPI.get( + path, + parameters: [:], + success: { responseProducts, _ in + guard let productsDictionary = responseProducts as? [String: [String: Any]] else { + completion(.failure(GetProductError.failedCastingProductsToDictionary(responseProducts))) + return + } + + let products = productsDictionary.compactMap { (key: String, value: [String: Any]) -> Product? in + guard let productID = value["product_id"] as? Int else { + return nil + } + + let name = (value["product_name"] as? String) ?? "" + let slug = (value["product_slug"] as? String) ?? "" + let description = (value["description"] as? String) ?? "" + let currencyCode = value["currency_code"] as? String + let saleCost = value["sale_cost"] as? Double + + return Product( + id: productID, + key: key, + name: name, + slug: slug, + description: description, + currencyCode: currencyCode, + saleCost: saleCost) + } + + completion(.success(products)) + }, + failure: { error, _ in + completion(.failure(error)) + } + ) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/PushAuthenticationServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/PushAuthenticationServiceRemote.swift new file mode 100644 index 000000000000..46324e854dc4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/PushAuthenticationServiceRemote.swift @@ -0,0 +1,31 @@ +import Foundation + +/// The purpose of this class is to encapsulate all of the interaction with the REST endpoint, +/// required to handle WordPress.com 2FA Code Veritication via Push Notifications +/// +@objc open class PushAuthenticationServiceRemote: ServiceRemoteWordPressComREST { + /// Verifies a WordPress.com Login. + /// + /// - Parameters: + /// - token: The token passed on by WordPress.com's 2FA Push Notification. + /// - success: Closure to be executed on success. Can be nil. + /// - failure: Closure to be executed on failure. Can be nil. + /// + @objc open func authorizeLogin(_ token: String, success: (() -> Void)?, failure: (() -> Void)?) { + let path = "me/two-step/push-authentication" + let requestUrl = self.path(forEndpoint: path, withVersion: ._1_1) + + let parameters = [ + "action": "authorize_login", + "push_token": token + ] + + wordPressComRESTAPI.post(requestUrl, parameters: parameters, + success: { _, _ in + success?() + }, + failure: { _, _ in + failure?() + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginServiceRemote.swift new file mode 100644 index 000000000000..db2335278bfa --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginServiceRemote.swift @@ -0,0 +1,63 @@ +import Foundation +import WordPressShared + +open class QRLoginServiceRemote: ServiceRemoteWordPressComREST { + /// Validates the incoming QR Login token and retrieves the requesting browser, and location + open func validate(token: String, data: String, success: @escaping (QRLoginValidationResponse) -> Void, failure: @escaping (Error?, QRLoginError?) -> Void) { + let path = self.path(forEndpoint: "auth/qr-code/validate", withVersion: ._2_0) + let parameters = [ "token": token, "data": data ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters as [String: AnyObject], success: { (response, _) in + do { + let decoder = JSONDecoder.apiDecoder + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(QRLoginValidationResponse.self, from: data) + + success(envelope) + } catch { + failure(nil, .invalidData) + } + }, failure: { (error, response) in + guard let response = response else { + failure(error, .invalidData) + return + } + + let statusCode = response.statusCode + failure(error, QRLoginError(statusCode: statusCode)) + }) + } + + /// Authenticates the users browser + open func authenticate(token: String, data: String, success: @escaping(Bool) -> Void, failure: @escaping(Error) -> Void) { + let path = self.path(forEndpoint: "auth/qr-code/authenticate", withVersion: ._2_0) + let parameters = [ "token": token, "data": data ] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { (response, _) in + guard let responseDict = response as? [String: Any], + let authenticated = responseDict["authenticated"] as? Bool else { + success(false) + return + } + + success(authenticated) + }, failure: { (error, _) in + failure(error) + }) + } +} + +public enum QRLoginError { + case invalidData + case expired + + init(statusCode: Int) { + switch statusCode { + case 401: + self = .expired + + default: + self = .invalidData + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginValidationResponse.swift b/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginValidationResponse.swift new file mode 100644 index 000000000000..5670cba3749d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/QR Login/QRLoginValidationResponse.swift @@ -0,0 +1,12 @@ +import Foundation + +public struct QRLoginValidationResponse: Decodable { + /// The name of the browser that the user has requested the login from + /// IE: Chrome, Firefox + /// This may be null if the browser could not be determined + public var browser: String? + + /// The City, State the user has requested the login from + /// IE: Columbus, Ohio + public var location: String +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Cards.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Cards.swift new file mode 100644 index 000000000000..36ae18df797c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Cards.swift @@ -0,0 +1,117 @@ +public enum ReaderSortingOption: String, CaseIterable { + case popularity + case date + case noSorting + + var queryValue: String? { + guard self != .noSorting else { + return nil + } + return rawValue + } +} + +extension ReaderPostServiceRemote { + /// Returns a collection of RemoteReaderCard using the tags API + /// a Reader Card can represent an item for the reader feed, such as + /// - Reader Post + /// - Topics you may like + /// - Blogs you may like and so on + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchCards(for topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + refreshCount: Int? = nil, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + let path = "read/tags/cards" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + refreshCount: refreshCount) else { + return + } + fetch(requestUrl, success: success, failure: failure) + } + + /// Returns a collection of RemoteReaderCard using the discover streams API + /// a Reader Card can represent an item for the reader feed, such as + /// - Reader Post + /// - Topics you may like + /// - Blogs you may like and so on + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter sortingOption: a ReaderSortingOption that represents a sorting option + /// - Parameter count: the number of cards to fetch. Warning: This also changes the number of objects returned for recommended sites/tags. + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchStreamCards(for topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + refreshCount: Int? = nil, + count: Int? = nil, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + let path = "read/streams/discover" + guard let requestUrl = cardsEndpoint(with: path, + topics: topics, + page: page, + sortingOption: sortingOption, + count: count, + refreshCount: refreshCount) else { + return + } + fetch(requestUrl, success: success, failure: failure) + } + + private func fetch(_ endpoint: String, + success: @escaping ([RemoteReaderCard], String?) -> Void, + failure: @escaping (Error) -> Void) { + Task { @MainActor [wordPressComRestApi] in + await wordPressComRestApi.perform(.get, URLString: endpoint, type: ReaderCardEnvelope.self) + .map { ($0.body.cards, $0.body.nextPageHandle) } + .mapError { error -> Error in error.asNSError() } + .execute(onSuccess: success, onFailure: failure) + } + } + + private func cardsEndpoint(with path: String, + topics: [String], + page: String? = nil, + sortingOption: ReaderSortingOption = .noSorting, + count: Int? = nil, + refreshCount: Int? = nil) -> String? { + var path = URLComponents(string: path) + + path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) } + + if let page { + path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page)) + } + + if let sortingOption = sortingOption.queryValue { + path?.queryItems?.append(URLQueryItem(name: "sort", value: sortingOption)) + } + + if let count { + path?.queryItems?.append(URLQueryItem(name: "count", value: String(count))) + } + + if let refreshCount { + path?.queryItems?.append(URLQueryItem(name: "refresh", value: String(refreshCount))) + } + + guard let endpoint = path?.string else { + return nil + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+RelatedPosts.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+RelatedPosts.swift new file mode 100644 index 000000000000..b65851fd9a9b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+RelatedPosts.swift @@ -0,0 +1,48 @@ +import Foundation + +extension ReaderPostServiceRemote { + + /// Returns a collection of RemoteReaderSimplePost + /// This method returns related posts for a source post. + /// + /// - Parameter postID: The source post's ID + /// - Parameter siteID: The source site's ID + /// - Parameter count: The number of related posts to retrieve for each post type + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchRelatedPosts(for postID: Int, + from siteID: Int, + count: Int? = 2, + success: @escaping ([RemoteReaderSimplePost]) -> Void, + failure: @escaping (Error?) -> Void) { + + let endpoint = "read/site/\(siteID)/post/\(postID)/related" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_2) + + let parameters = [ + "size_local": count, + "size_global": count + ] as [String: AnyObject] + + wordPressComRESTAPI.get( + path, + parameters: parameters, + success: { (response, _) in + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(RemoteReaderSimplePostEnvelope.self, from: data) + + success(envelope.posts) + } catch { + WPKitLogError("Error parsing the reader related posts response: \(error)") + failure(error) + } + }, + failure: { (error, _) in + WPKitLogError("Error fetching reader related posts: \(error)") + failure(error) + } + ) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Subscriptions.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Subscriptions.swift new file mode 100644 index 000000000000..9661cf4794c0 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+Subscriptions.swift @@ -0,0 +1,145 @@ +import Foundation + +extension ReaderPostServiceRemote { + + public enum ResponseError: Error { + case decodingFailed + } + + private enum Constants { + static let isSubscribed = "i_subscribe" + static let success = "success" + static let receivesNotifications = "receives_notifications" + + /// Request parameter key used for updating the notification settings of a post subscription. + static let receiveNotificationsRequestKey = "receive_notifications" + } + + /// Fetches the subscription status of the specified post for the current user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func fetchSubscriptionStatus(for postID: Int, + from siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let isSubscribed = responseObject[Constants.isSubscribed] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(isSubscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error fetching subscription status: \(error)") + failure(error) + } + } + + /// Mark a post as subscribed by the user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func subscribeToPost(with postID: Int, + for siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/new", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let subscribed = responseObject[Constants.success] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(subscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error subscribing to comments in the post: \(error)") + failure(error) + } + } + + /// Mark a post as unsubscribed by the user. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - success: Success block called on a successful fetch. + /// - failure: Failure block called if there is any error. + @objc open func unsubscribeFromPost(with postID: Int, + for siteID: Int, + success: @escaping (Bool) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine/delete", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, parameters: nil, success: { response, _ in + do { + guard let responseObject = response as? [String: AnyObject], + let unsubscribed = responseObject[Constants.success] as? Bool else { + throw ReaderPostServiceRemote.ResponseError.decodingFailed + } + + success(unsubscribed) + } catch { + failure(error) + } + }) { error, _ in + WPKitLogError("Error unsubscribing from comments in the post: \(error)") + failure(error) + } + } + + /// Updates the notification settings for a post subscription. + /// + /// When the `receivesNotification` parameter is set to `true`, the subscriber will receive a notification whenever there is a new comment on the + /// subscribed post. Note that the subscriber will still receive emails. On the contrary, when the `receivesNotification` parameter is set to `false`, + /// subscriber will no longer receive notifications for new comments, but will still receive emails. To fully unsubscribe, refer to the + /// `unsubscribeFromPost` method. + /// + /// - Parameters: + /// - postID: The ID of the post. + /// - siteID: The ID of the site. + /// - receiveNotifications: When the value is true, subscriber will also receive a push notification for new comments on the subscribed post. + /// - success: Closure called when the request has succeeded. + /// - failure: Closure called when the request has failed. + @objc open func updateNotificationSettingsForPost(with postID: Int, + siteID: Int, + receiveNotifications: Bool, + success: @escaping () -> Void, + failure: @escaping (Error?) -> Void) { + let path = self.path(forEndpoint: "sites/\(siteID)/posts/\(postID)/subscribers/mine/update", withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: [Constants.receiveNotificationsRequestKey: receiveNotifications] as [String: AnyObject], + success: { response, _ in + guard let responseObject = response as? [String: AnyObject], + let remoteReceivesNotifications = responseObject[Constants.receivesNotifications] as? Bool, + remoteReceivesNotifications == receiveNotifications else { + failure(ResponseError.decodingFailed) + return + } + + success() + + }, failure: { error, _ in + WPKitLogError("Error updating post subscription: \(error)") + failure(error) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+V2.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+V2.swift new file mode 100644 index 000000000000..d085ec2e0f5f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote+V2.swift @@ -0,0 +1,129 @@ +extension ReaderPostServiceRemote { + /// Returns a collection of RemoteReaderPost + /// This method returns the best available content for the given topics. + /// + /// - Parameter topics: an array of String representing the topics + /// - Parameter page: a String that represents a page handle + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchPosts(for topics: [String], + page: String? = nil, + refreshCount: Int? = nil, + success: @escaping ([RemoteReaderPost], String?) -> Void, + failure: @escaping (Error) -> Void) { + guard let requestUrl = postsEndpoint(for: topics, page: page) else { + return + } + + wordPressComRESTAPI.get(requestUrl, + parameters: nil, + success: { response, _ in + let responseDict = response as? [String: Any] + let nextPageHandle = responseDict?["next_page_handle"] as? String + let postsDictionary = responseDict?["posts"] as? [[String: Any]] + let posts = postsDictionary?.compactMap { RemoteReaderPost(dictionary: $0) } ?? [] + success(posts, nextPageHandle) + }, failure: { error, _ in + WPKitLogError("Error fetching reader posts: \(error)") + failure(error) + }) + } + + private func postsEndpoint(for topics: [String], page: String? = nil) -> String? { + var path = URLComponents(string: "read/tags/posts") + + path?.queryItems = topics.map { URLQueryItem(name: "tags[]", value: $0) } + + if let page = page { + path?.queryItems?.append(URLQueryItem(name: "page_handle", value: page)) + } + + guard let endpoint = path?.string else { + return nil + } + + return self.path(forEndpoint: endpoint, withVersion: ._2_0) + } + + /// Sets the `is_seen` status for a given feed post. + /// + /// - Parameter seen: the post is to be marked seen or not (unseen) + /// - Parameter feedID: feedID of the ReaderPost + /// - Parameter feedItemID: feedItemID of the ReaderPost + /// - Parameter success: Called when the request succeeds + /// - Parameter failure: Called when the request fails + @objc + public func markFeedPostSeen(seen: Bool, + feedID: NSNumber, + feedItemID: NSNumber, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = seen ? SeenEndpoints.feedSeen : SeenEndpoints.feedUnseen + + let params = [ + "feed_id": feedID, + "feed_item_ids": [feedItemID], + "source": "reader-ios" + ] as [String: AnyObject] + + updateSeenStatus(endpoint: endpoint, params: params, success: success, failure: failure) + } + + /// Sets the `is_seen` status for a given blog post. + /// + /// - Parameter seen: the post is to be marked seen or not (unseen) + /// - Parameter blogID: blogID of the ReaderPost + /// - Parameter postID: postID of the ReaderPost + /// - Parameter success: Called when the request succeeds + /// - Parameter failure: Called when the request fails + @objc + public func markBlogPostSeen(seen: Bool, + blogID: NSNumber, + postID: NSNumber, + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + let endpoint = seen ? SeenEndpoints.blogSeen : SeenEndpoints.blogUnseen + + let params = [ + "blog_id": blogID, + "post_ids": [postID], + "source": "reader-ios" + ] as [String: AnyObject] + + updateSeenStatus(endpoint: endpoint, params: params, success: success, failure: failure) + } + + private func updateSeenStatus(endpoint: String, + params: [String: AnyObject], + success: @escaping (() -> Void), + failure: @escaping ((Error) -> Void)) { + + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.post(path, parameters: params, success: { (responseObject, _) in + guard let response = responseObject as? [String: AnyObject], + let status = response["status"] as? Bool, + status == true else { + failure(MarkSeenError.failed) + return + } + success() + }, failure: { (error, _) in + failure(error) + }) + } + + private struct SeenEndpoints { + // Creates a new `seen` entry (i.e. mark as seen) + static let feedSeen = "seen-posts/seen/new" + static let blogSeen = "seen-posts/seen/blog/new" + // Removes the `seen` entry (i.e. mark as unseen) + static let feedUnseen = "seen-posts/seen/delete" + static let blogUnseen = "seen-posts/seen/blog/delete" + } + + private enum MarkSeenError: Error { + case failed + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.h new file mode 100644 index 000000000000..5e135bafb0d7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.h @@ -0,0 +1,100 @@ +#import +#import + +@class RemoteReaderPost; + +@interface ReaderPostServiceRemote : ServiceRemoteWordPressComREST + +/** + Fetches the posts from the specified remote endpoint + + @param algorithm meta data used in paging + @param count number of posts to fetch. + @param date the date to fetch posts before. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + before:(NSDate *)date + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches the posts from the specified remote endpoint + + @param algorithm meta data used in paging + @param count number of posts to fetch. + @param offset The offset of the fetch. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + offset:(NSUInteger)offset + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure; + +/** + Fetches a specific post from the specified remote site + + @param postID the ID of the post to fetch + @param siteID the ID of the site the post belongs to + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPost:(NSUInteger)postID + fromSite:(NSUInteger)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches a specific post from the specified URL + + @param postURL The URL of the post to fetch + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure; + +/** + Mark a post as liked by the user. + + @param postID The ID of the post. + @param siteID The ID of the site. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)likePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + Mark a post as unliked by the user. + + @param postID The ID of the post. + @param siteID The ID of the site. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unlikePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure; + +/** + A helper method for constructing the endpoint URL for a reader search request. + + @param phrase The search phrase + + @return The endpoint URL as a string. + */ +- (NSString *)endpointUrlForSearchPhrase:(NSString *)phrase; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.m new file mode 100644 index 000000000000..6cafe5f1a431 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderPostServiceRemote.m @@ -0,0 +1,273 @@ +#import "ReaderPostServiceRemote.h" +#import "RemoteReaderPost.h" +#import "RemoteSourcePostAttribution.h" +#import "ReaderTopicServiceRemote.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +NSString * const PostRESTKeyPosts = @"posts"; + +// Param keys +NSString * const ParamsKeyAlgorithm = @"algorithm"; +NSString * const ParamKeyBefore = @"before"; +NSString * const ParamKeyMeta = @"meta"; +NSString * const ParamKeyNumber = @"number"; +NSString * const ParamKeyOffset = @"offset"; +NSString * const ParamKeyOrder = @"order"; +NSString * const ParamKeyDescending = @"DESC"; +NSString * const ParamKeyMetaValue = @"site,feed"; + +@implementation ReaderPostServiceRemote + +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + before:(NSDate *)date + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *error))failure +{ + NSNumber *numberToFetch = @(count); + NSMutableDictionary *params = [@{ + ParamKeyNumber:numberToFetch, + ParamKeyBefore: [DateUtils isoStringFromDate:date], + ParamKeyOrder: ParamKeyDescending, + ParamKeyMeta: ParamKeyMetaValue + } mutableCopy]; + if (algorithm) { + params[ParamsKeyAlgorithm] = algorithm; + } + + [self fetchPostsFromEndpoint:endpoint withParameters:params success:success failure:failure]; +} + +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + algorithm:(NSString *)algorithm + count:(NSUInteger)count + offset:(NSUInteger)offset + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure +{ + NSMutableDictionary *params = [@{ + ParamKeyNumber:@(count), + ParamKeyOffset: @(offset), + ParamKeyOrder: ParamKeyDescending, + ParamKeyMeta: ParamKeyMetaValue + } mutableCopy]; + if (algorithm) { + params[ParamsKeyAlgorithm] = algorithm; + } + [self fetchPostsFromEndpoint:endpoint withParameters:params success:success failure:failure]; +} + +- (void)fetchPost:(NSUInteger)postID + fromSite:(NSUInteger)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure { + + NSString *feedType = (isFeed) ? @"feed" : @"sites"; + NSString *path = [NSString stringWithFormat:@"read/%@/%lu/posts/%lu/?meta=site", feedType, (unsigned long)siteID, (unsigned long)postID]; + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary: (NSDictionary *)responseObject]; + dispatch_async(dispatch_get_main_queue(), ^{ + success(post); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +/** + Fetches a specific post from the specified URL + + @param postURL The URL of the post to fetch + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostAtURL:(NSURL *)postURL + success:(void (^)(RemoteReaderPost *post))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [self apiPathForPostAtURL:postURL]; + + if (!path) { + failure(nil); + return; + } + + [self.wordPressComRESTAPI get:path + parameters:nil + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary:(NSDictionary *)responseObject]; + dispatch_async(dispatch_get_main_queue(), ^{ + success(post); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)likePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/posts/%lu/likes/new", (unsigned long)siteID, (unsigned long)postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unlikePost:(NSUInteger)postID + forSite:(NSUInteger)siteID + success:(void (^)(void))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/posts/%lu/likes/mine/delete", (unsigned long)siteID, (unsigned long)postID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (NSString *)endpointUrlForSearchPhrase:(NSString *)phrase +{ + NSAssert([phrase length] > 0, @"A search phrase is required."); + + NSString *endpoint = [NSString stringWithFormat:@"read/search?q=%@", [phrase stringByUrlEncoding]]; + NSString *absolutePath = [self pathForEndpoint:endpoint withVersion:WordPressComRESTAPIVersion_1_2]; + NSURL *url = [NSURL URLWithString:absolutePath relativeToURL:self.wordPressComRESTAPI.baseURL]; + return [url absoluteString]; +} + + +#pragma mark - Private Methods + +/** + Fetches the posts from the specified remote endpoint + + @param params A dictionary of parameters supported by the endpoint. Params are converted to the request's query string. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchPostsFromEndpoint:(NSURL *)endpoint + withParameters:(NSDictionary *)params + success:(void (^)(NSArray *posts, NSString *algorithm))success + failure:(void (^)(NSError *))failure +{ + NSString *path = [endpoint absoluteString]; + [self.wordPressComRESTAPI get:path + parameters:params + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{ + // NOTE: Do all of this work on a background thread, then call success on the main thread. + // Do this to avoid any chance of blocking the UI while parsing. + + // NOTE: If an offset param was specified sortRank will be derived + // from the offset + order of the results, ONLY if a `before` param + // was not specified. If a `before` param exists we favor sorting by date. + BOOL rankByOffset = [params objectForKey:ParamKeyOffset] != nil && [params objectForKey:ParamKeyBefore] == nil; + __block CGFloat offset = [[params numberForKey:ParamKeyOffset] floatValue]; + NSString *algorithm = [responseObject stringForKey:ParamsKeyAlgorithm]; + NSArray *jsonPosts = [responseObject arrayForKey:PostRESTKeyPosts]; + NSArray *posts = [jsonPosts wp_map:^id(NSDictionary *jsonPost) { + if (rankByOffset) { + RemoteReaderPost *post = [self formatPostDictionary:jsonPost offset:offset]; + offset++; + return post; + } + return [[RemoteReaderPost alloc] initWithDictionary:jsonPost]; + }]; + + // Now call success on the main thread. + dispatch_async(dispatch_get_main_queue(), ^{ + success(posts, algorithm); + }); + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteReaderPost *)formatPostDictionary:(NSDictionary *)dict offset:(CGFloat)offset +{ + RemoteReaderPost *post = [[RemoteReaderPost alloc] initWithDictionary:dict]; + // It's assumed that sortRank values are in descending order. Since + // offsets are ascending, we store its negative to ensure we get a proper sort order. + CGFloat adjustedOffset = -offset; + post.sortRank = @(adjustedOffset); + return post; +} + +- (nullable NSString *)apiPathForPostAtURL:(NSURL *)url +{ + NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + + NSString *hostname = components.host; + NSArray *pathComponents = [[components.path pathComponents] filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"SELF != '/'"] ]; + NSString *slug = [components.path lastPathComponent]; + + // We expect 4 path components for a post – year, month, day, slug, plus a '/' on either end + if (hostname == nil || pathComponents.count != 4 || slug == nil) { + return nil; + } + + NSString *path = [NSString stringWithFormat:@"sites/%@/posts/slug:%@?meta=site,likes", hostname, slug]; + + return [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderServiceDeliveryFrequency.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderServiceDeliveryFrequency.swift new file mode 100644 index 000000000000..96090898ee27 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderServiceDeliveryFrequency.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Delivery frequency values for email notifications +/// +/// - daily: daily frequency +/// - instantly: instantly frequency +/// - weekly: weekly frequency +public enum ReaderServiceDeliveryFrequency: String { + case daily + case instantly + case weekly +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderSiteSearchServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteSearchServiceRemote.swift new file mode 100644 index 000000000000..ae03296929ce --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteSearchServiceRemote.swift @@ -0,0 +1,85 @@ +import Foundation +import WordPressShared + +public class ReaderSiteSearchServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + /// Searches Reader for sites matching the specified query. + /// + /// - Parameters: + /// - query: A search string to match + /// - offset: The first N results to skip when returning results. + /// - count: Number of objects to retrieve. + /// - success: Closure to be executed on success. Is passed an array of + /// ReaderFeeds, a boolean indicating if there's more results + /// to fetch, and a total feed count. + /// - failure: Closure to be executed on error. + /// + public func performSearch(_ query: String, + offset: Int = 0, + count: Int, + success: @escaping (_ results: [ReaderFeed], _ hasMore: Bool, _ feedCount: Int) -> Void, + failure: @escaping (Error) -> Void) { + let endpoint = "read/feed" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters: [String: AnyObject] = [ + "number": count as AnyObject, + "offset": offset as AnyObject, + "exclude_followed": false as AnyObject, + "sort": "relevance" as AnyObject, + "meta": "site" as AnyObject, + "q": query as AnyObject + ] + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { response, _ in + do { + let (results, total) = try self.mapSearchResponse(response) + let hasMore = total > (offset + count) + success(results, hasMore, total) + } catch { + failure(error) + } + }, failure: { error, _ in + WPKitLogError("\(error)") + failure(error) + }) + } +} + +private extension ReaderSiteSearchServiceRemote { + + func mapSearchResponse(_ response: Any) throws -> ([ReaderFeed], Int) { + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(ReaderFeedEnvelope.self, from: data) + + // Filter out any feeds that don't have either a feed ID or a blog ID + let feeds = envelope.feeds.filter({ $0.feedID != nil || $0.blogID != nil }) + return (feeds, envelope.total) + } catch { + WPKitLogError("\(error)") + WPKitLogDebug("Full response: \(response)") + throw ReaderSiteSearchServiceRemote.ResponseError.decodingFailure + } + } +} + +/// ReaderFeedEnvelope +/// The Reader feed search endpoint returns feeds in a key named `feeds` key. +/// This entity allows us to do parse that and the total feed count using JSONDecoder. +/// +private struct ReaderFeedEnvelope: Decodable { + let feeds: [ReaderFeed] + let total: Int + + private enum CodingKeys: String, CodingKey { + case feeds = "feeds" + case total = "total" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.h new file mode 100644 index 000000000000..94044cfeb150 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.h @@ -0,0 +1,126 @@ +#import +#import + +typedef NS_ENUM(NSUInteger, ReaderSiteServiceRemoteError) { + ReaderSiteServiceRemoteInvalidHost, + ReaderSiteServiceRemoteUnsuccessfulFollowSite, + ReaderSiteServiceRemoteUnsuccessfulUnfollowSite, + ReaderSiteSErviceRemoteUnsuccessfulBlockSite +}; + +extern NSString * const ReaderSiteServiceRemoteErrorDomain; + +@interface ReaderSiteServiceRemote : ServiceRemoteWordPressComREST + +/** + Get a list of the sites the user follows. + + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success + failure:(void(^)(NSError *error))failure; + + +/** + Follow a wpcom site. + + @param siteID The ID of the site. + @param success block called on a successful follow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followSiteWithID:(NSUInteger)siteID + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollow a wpcom site + + @param siteID The ID of the site. + @param success block called on a successful unfollow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowSiteWithID:(NSUInteger)siteID + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Follow a wporg site. + + @param siteURL The URL of the site as a string. + @param success block called on a successful follow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followSiteAtURL:(NSString *)siteURL + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollow a wporg site + + @param siteURL The URL of the site as a string. + @param success block called on a successful unfollow. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowSiteAtURL:(NSString *)siteURL + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Find the WordPress.com site ID for the site at the specified URL. + + @param siteURL the URL of the site. + @param success block called on a successful fetch. The found siteID is passed to the success block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)findSiteIDForURL:(NSURL *)siteURL + success:(void(^)(NSUInteger siteID))success + failure:(void(^)(NSError *error))failure; + +/** + Test a URL to see if a site exists. + + @param siteURL the URL of the site. + @param success block called on a successful request. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSiteExistsAtURL:(NSURL *)siteURL + success:(void (^)(void))success + failure:(void(^)(NSError *error))failure; + +/** + Check whether a site is already subscribed + + @param siteID The ID of the site. + @param success block called on a successful check. A boolean is returned indicating if the site is followed or not. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSubscribedToSiteByID:(NSUInteger)siteID + success:(void (^)(BOOL follows))success + failure:(void(^)(NSError *error))failure; + +/** + Check whether a feed is already subscribed + + @param siteURL the URL of the site. + @param success block called on a successful check. A boolean is returned indicating if the feed is followed or not. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)checkSubscribedToFeedByURL:(NSURL *)siteURL + success:(void (^)(BOOL follows))success + failure:(void(^)(NSError *error))failure; + +/** + Block/unblock a site from showing its posts in the reader + + @param siteID The ID of the site (not feed). + @param blocked Boolean value. Yes if the site should be blocked. NO if the site should be unblocked. + @param success block called on a successful check. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)flagSiteWithID:(NSUInteger)siteID + asBlocked:(BOOL)blocked + success:(void(^)(void))success + failure:(void(^)(NSError *error))failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.m new file mode 100644 index 000000000000..44d278eb925d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderSiteServiceRemote.m @@ -0,0 +1,359 @@ +#import "ReaderSiteServiceRemote.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +static NSString* const ReaderSiteServiceRemoteURLKey = @"url"; +static NSString* const ReaderSiteServiceRemoteSourceKey = @"source"; +static NSString* const ReaderSiteServiceRemoteSourceValue = @"ios"; + +NSString * const ReaderSiteServiceRemoteErrorDomain = @"ReaderSiteServiceRemoteErrorDomain"; + +@implementation ReaderSiteServiceRemote + +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine?meta=site,feed"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *response = (NSDictionary *)responseObject; + NSArray *subscriptions = [response arrayForKey:@"subscriptions"]; + NSMutableArray *sites = [NSMutableArray array]; + for (NSDictionary *dict in subscriptions) { + RemoteReaderSite *site = [self normalizeSiteDictionary:dict]; + site.isSubscribed = YES; + [sites addObject:site]; + } + success(sites); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followSiteWithID:(NSUInteger)siteID success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/new?%@=%@", (unsigned long)siteID, ReaderSiteServiceRemoteSourceKey, ReaderSiteServiceRemoteSourceValue]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowSiteWithID:(NSUInteger)siteID success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/mine/delete", (unsigned long)siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followSiteAtURL:(NSString *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine/new"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *params = @{ReaderSiteServiceRemoteURLKey: siteURL, + ReaderSiteServiceRemoteSourceKey: ReaderSiteServiceRemoteSourceValue}; + [self.wordPressComRESTAPI post:requestUrl parameters:params success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL subscribed = [[dict numberForKey:@"subscribed"] boolValue]; + if (!subscribed) { + if (failure) { + WPKitLogError(@"Error following site at url: %@", siteURL); + NSError *error = [self errorForUnsuccessfulFollowSite]; + failure(error); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowSiteAtURL:(NSString *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine/delete"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary *params = @{ReaderSiteServiceRemoteURLKey: siteURL}; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL subscribed = [[dict numberForKey:@"subscribed"] boolValue]; + if (subscribed) { + if (failure) { + WPKitLogError(@"Error unfollowing site at url: %@", siteURL); + NSError *error = [self errorForUnsuccessfulFollowSite]; + failure(error); + } + return; + } + if (success) { + success(); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)findSiteIDForURL:(NSURL *)siteURL success:(void (^)(NSUInteger siteID))success failure:(void(^)(NSError *error))failure +{ + NSString *host = [siteURL host]; + if (!host) { + // error; + if (failure) { + NSError *error = [self errorForInvalidHost]; + failure(error); + } + return; + } + + // Define success block + void (^successBlock)(id responseObject, NSHTTPURLResponse *response) = ^void(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + NSUInteger siteID = [[dict numberForKey:@"ID"] integerValue]; + success(siteID); + }; + + // Define failure block + void (^failureBlock)(NSError *error, NSHTTPURLResponse *httpResponse) = ^void(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }; + + NSString *path = [NSString stringWithFormat:@"sites/%@", host]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:successBlock failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + NSString *newHost; + if ([host hasPrefix:@"www."]) { + // If the provided host includes a www. prefix, try again without it. + newHost = [host substringFromIndex:4]; + + } else { + // If the provided host includes a www. prefix, try again without it. + newHost = [NSString stringWithFormat:@"www.%@", host]; + + } + NSString *newPath = [NSString stringWithFormat:@"sites/%@", newHost]; + NSString *newPathRequestUrl = [self pathForEndpoint:newPath + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:newPathRequestUrl parameters:nil success:successBlock failure:failureBlock]; + }]; +} + +- (void)checkSiteExistsAtURL:(NSURL *)siteURL success:(void (^)(void))success failure:(void(^)(NSError *error))failure +{ + NSURLSession *session = NSURLSession.sharedSession; + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:siteURL cachePolicy:NSURLRequestReloadIgnoringLocalAndRemoteCacheData timeoutInterval:5]; + request.HTTPMethod = @"HEAD"; + NSURLSessionTask *task = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + if (error) { + if (failure) { + failure(error); + } + return; + } + if([response isKindOfClass:[NSHTTPURLResponse class]]) { + NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; + NSIndexSet *acceptableStatus = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(200, 99)]; + if (![acceptableStatus containsIndex:httpResponse.statusCode]) { + if (failure) { + NSError *statusError = [NSError errorWithDomain:(NSString *)kCFErrorDomainCFNetwork code:kCFFTPErrorUnexpectedStatusCode userInfo:nil]; + failure(statusError); + } + return; + } + } + if (success) { + success(); + } + }]; + [task resume]; +} + +- (void)checkSubscribedToSiteByID:(NSUInteger)siteID success:(void (^)(BOOL follows))success failure:(void(^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%lu/follows/mine", (unsigned long)siteID]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *dict = (NSDictionary *)responseObject; + BOOL follows = [[dict numberForKey:@"is_following"] boolValue]; + success(follows); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)checkSubscribedToFeedByURL:(NSURL *)siteURL success:(void (^)(BOOL follows))success failure:(void(^)(NSError *error))failure +{ + NSString *path = @"read/following/mine"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + BOOL follows = NO; + NSString *responseString = [[responseObject description] stringByRemovingPercentEncoding]; + if ([responseString rangeOfString:[siteURL absoluteString]].location != NSNotFound) { + follows = YES; + } + success(follows); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)flagSiteWithID:(NSUInteger)siteID asBlocked:(BOOL)blocked success:(void(^)(void))success failure:(void(^)(NSError *error))failure +{ + NSString *path; + if (blocked) { + path = [NSString stringWithFormat:@"me/block/sites/%lu/new", (unsigned long)siteID]; + } else { + path = [NSString stringWithFormat:@"me/block/sites/%lu/delete", (unsigned long)siteID]; + } + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *dict = (NSDictionary *)responseObject; + if (![[dict numberForKey:@"success"] boolValue]) { + if (blocked) { + failure([self errorForUnsuccessfulBlockSite]); + } else { + failure([self errorForUnsuccessfulUnblockSite]); + } + return; + } + + if (success) { + success(); + } + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + +#pragma mark - Private Methods + +- (RemoteReaderSite *)normalizeSiteDictionary:(NSDictionary *)dict +{ + NSDictionary *meta = [dict dictionaryForKeyPath:@"meta.data.site"]; + if (!meta) { + meta = [dict dictionaryForKeyPath:@"meta.data.feed"]; + } + + RemoteReaderSite *site = [[RemoteReaderSite alloc] init]; + site.recordID = [dict numberForKey:@"ID"]; + site.path = [dict stringForKey:@"URL"]; // Retrieve from the parent dictionary due to a bug in the REST API that returns NULL in the feed dictionary in some cases. + site.siteID = [meta numberForKey:@"ID"]; + site.feedID = [meta numberForKey:@"feed_ID"]; + site.name = [meta stringForKey:@"name"]; + if ([site.name length] == 0) { + site.name = site.path; + } + site.icon = [meta stringForKeyPath:@"icon.img"]; + + return site; +} + +- (NSError *)errorForInvalidHost +{ + NSString *description = NSLocalizedString(@"The URL is missing a valid host.", @"Error message describing a problem with a URL."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteInvalidHost userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulFollowSite +{ + NSString *description = NSLocalizedString(@"Could not follow the site at the address specified.", @"Error message informing the user that there was a problem subscribing to a site or feed."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteUnsuccessfulFollowSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulUnfollowSite +{ + NSString *description = NSLocalizedString(@"Could not unfollow the site at the address specified.", @"Error message informing the user that there was a problem unsubscribing to a site or feed."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteServiceRemoteUnsuccessfulUnfollowSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulBlockSite +{ + NSString *description = NSLocalizedString(@"There was a problem blocking posts from the specified site.", @"Error message informing the user that there was a problem blocking posts from a site from their reader."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteSErviceRemoteUnsuccessfulBlockSite userInfo:userInfo]; + return error; +} + +- (NSError *)errorForUnsuccessfulUnblockSite +{ + NSString *description = NSLocalizedString(@"There was a problem removing the block for specified site.", @"Error message informing the user that there was a problem clearing the block on site preventing its posts from displaying in the reader."); + NSDictionary *userInfo = @{NSLocalizedDescriptionKey:description}; + NSError *error = [[NSError alloc] initWithDomain:ReaderSiteServiceRemoteErrorDomain code:ReaderSiteSErviceRemoteUnsuccessfulBlockSite userInfo:userInfo]; + return error; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceError.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceError.swift new file mode 100644 index 000000000000..41431c39c495 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceError.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum ReaderTopicServiceError: Error { + case invalidId + case topicNotfound(id: Int) + case remoteResponse(message: String?, url: String) + + public var description: String { + switch self { + case .invalidId: + return "Invalid id: an id must be valid or not nil" + + case .topicNotfound(let id): + let localizedString = NSLocalizedString("Topic not found for id:", + comment: "Used when a Reader Topic is not found for a specific id") + return localizedString + " \(id)" + + case .remoteResponse(let message, let url): + let localizedString = NSLocalizedString("An error occurred while processing your request: ", + comment: "Used when a remote response doesn't have a specific message for a specific request") + return message ?? localizedString + " \(url)" + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Interests.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Interests.swift new file mode 100644 index 000000000000..93d064f218bc --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Interests.swift @@ -0,0 +1,55 @@ +import Foundation + +extension ReaderTopicServiceRemote { + /// Returns a collection of RemoteReaderInterest + /// - Parameters: + /// - Parameter success: Called when the request succeeds and the data returned is valid + /// - Parameter failure: Called if the request fails for any reason, or the response data is invalid + public func fetchInterests(_ success: @escaping ([RemoteReaderInterest]) -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "read/interests", withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + do { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: response, options: []) + let envelope = try decoder.decode(ReaderInterestEnvelope.self, from: data) + + success(envelope.interests) + } catch { + WPKitLogError("Error parsing the reader interests response: \(error)") + failure(error) + } + }, failure: { error, _ in + WPKitLogError("Error fetching reader interests: \(error)") + + failure(error) + }) + } + + /// Follows multiple tags/interests at once using their slugs + public func followInterests(withSlugs: [String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = self.path(forEndpoint: "read/tags/mine/new", withVersion: ._1_2) + let parameters = ["tags": withSlugs] as [String: AnyObject] + + wordPressComRESTAPI.post(path, parameters: parameters, success: { _, _ in + success() + }) { error, _ in + WPKitLogError("Error fetching reader interests: \(error)") + + failure(error) + } + } + + /// Returns an API path for the given tag/topic/interest slug + /// - Returns: https://_api_/read/tags/_slug_/posts + public func pathForTopic(slug: String) -> String { + let endpoint = path(forEndpoint: "read/tags/\(slug)/posts", withVersion: ._1_2) + + return wordPressComRESTAPI.baseURL.appendingPathComponent(endpoint).absoluteString + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Subscription.swift b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Subscription.swift new file mode 100644 index 000000000000..4e597b0dcf21 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote+Subscription.swift @@ -0,0 +1,96 @@ +import Foundation + +extension ReaderTopicServiceRemote { + private struct Delivery { + static let frequency = "delivery_frequency" + } + + /// Subscribe action for site notifications + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribeSiteNotifications(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .notifications(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unsubscribe action for site notifications + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribeSiteNotifications(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .notifications(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Subscribe action for site comments + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribeSiteComments(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .comments(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unubscribe action for site comments + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribeSiteComments(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .comments(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Subscribe action for post emails + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func subscribePostsEmail(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .postsEmail(siteId: siteId, action: .subscribe), success: success, failure: failure) + } + + /// Unsubscribe action for post emails + /// + /// - Parameters: + /// - siteId: A site id + /// - success: Success block + /// - failure: Failure block + @nonobjc public func unsubscribePostsEmail(with siteId: Int, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + POST(with: .postsEmail(siteId: siteId, action: .unsubscribe), success: success, failure: failure) + } + + /// Update action for posts email + /// + /// - Parameters: + /// - siteId: A site id + /// - frequency: The frequency value + /// - success: Success block + /// - failure: Failure block + @nonobjc public func updateFrequencyPostsEmail(with siteId: Int, frequency: ReaderServiceDeliveryFrequency, _ success: @escaping () -> Void, _ failure: @escaping (ReaderTopicServiceError?) -> Void) { + let parameters = [Delivery.frequency: NSString(string: frequency.rawValue)] + POST(with: .postsEmail(siteId: siteId, action: .update), parameters: parameters, success: success, failure: failure) + } + + // MARK: Private methods + + private func POST(with request: ReaderTopicServiceSubscriptionsRequest, parameters: [String: AnyObject]? = nil, success: @escaping () -> Void, failure: @escaping (ReaderTopicServiceError?) -> Void) { + let urlRequest = path(forEndpoint: request.path, withVersion: request.apiVersion) + + WPKitLogInfo("URL: \(urlRequest)") + + wordPressComRESTAPI.post(urlRequest, parameters: parameters, success: { (_, response) in + WPKitLogInfo("Success \(response?.url?.absoluteString ?? "unknown url")") + success() + }) { (error, response) in + WPKitLogError("Error: \(error.localizedDescription)") + let urlAbsoluteString = response?.url?.absoluteString ?? NSLocalizedString("unknown url", comment: "Used when the response doesn't have a valid url to display") + failure(ReaderTopicServiceError.remoteResponse(message: error.localizedDescription, url: urlAbsoluteString)) + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.h new file mode 100644 index 000000000000..4afc7877b62a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.h @@ -0,0 +1,119 @@ +#import +#import + +extern NSString * const WordPressComReaderEndpointURL; + +@class RemoteReaderSiteInfo; +@class RemoteReaderTopic; + +@interface ReaderTopicServiceRemote : ServiceRemoteWordPressComREST + +/** + Fetches the topics for the reader's menu from the remote service. + + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchReaderMenuWithSuccess:(void (^)(NSArray *topics))success + failure:(void (^)(NSError *error))failure; + +/** + Get a list of the sites the user follows with the default API parameters. + + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success + failure:(void(^)(NSError *error))failure DEPRECATED_MSG_ATTRIBUTE("Use fetchFollowedSitesForPage:number:success:failure: instead."); + +/** + Get a list of the sites the user follows with the specified API parameters. + + @param page The page number to fetch. + @param number The number of sites to fetch per page. + @param success block called on a successful fetch. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchFollowedSitesForPage:(NSUInteger)page + number:(NSUInteger)number + success:(void(^)(NSNumber *totalSites, NSArray *sites))success + failure:(void(^)(NSError *error))failure; + +/** + Unfollows the topic with the specified slug. + + @param slug The slug of the topic to unfollow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)unfollowTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Follows the topic with the specified name. + + @param topicName The name of the topic to follow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followTopicNamed:(NSString *)topicName + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Follows the topic with the specified slug. + + @param slug The slug of the topic to follow. + @param success block called on a successful fetch. An `NSArray` of `NSDictionary` + objects describing topics is passed as an argument. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)followTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches public information about the tag with the specified slug. + + @param slug The slug of the topic. + @param success block called on a successful fetch. An instance of RemoteReaderTopic + is passed to the callback block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchTagInfoForTagWithSlug:(NSString *)slug + success:(void (^)(RemoteReaderTopic *remoteTopic))success + failure:(void (^)(NSError *error))failure; + +/** + Fetches public information about the site with the specified ID. + + @param siteID The ID of the site. + @param isFeed If the site is a feed. + @param success block called on a successful fetch. An instance of RemoteReaderSiteInfo + is passed to the callback block. + @param failure block called if there is any error. `error` can be any underlying network error. + */ +- (void)fetchSiteInfoForSiteWithID:(NSNumber *)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderSiteInfo *siteInfo))success + failure:(void (^)(NSError *error))failure; + +/** + Takes a topic name and santitizes it, returning what *should* be its slug. + + @param topicName The natural language name of a topic. + + @return The sanitized name, as a topic slug. + */ +- (NSString *)slugForTopicName:(NSString *)topicName; + +/** + Returns a REST URL string for an endpoint path + @param path A partial path for the API call + */ +- (NSString *)endpointUrlForPath:(NSString *)path; +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.m new file mode 100644 index 000000000000..b78003d65e61 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ReaderTopicServiceRemote.m @@ -0,0 +1,330 @@ +#import "ReaderTopicServiceRemote.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +static NSString * const TopicMenuSectionDefaultKey = @"default"; +static NSString * const TopicMenuSectionSubscribedKey = @"subscribed"; +static NSString * const TopicMenuSectionRecommendedKey = @"recommended"; +static NSString * const TopicRemovedTagKey = @"removed_tag"; +static NSString * const TopicAddedTagKey = @"added_tag"; +static NSString * const TopicDictionaryTagKey = @"tag"; +static NSString * const TopicNotFoundMarker = @"-notfound-"; + +@implementation ReaderTopicServiceRemote + +- (void)fetchReaderMenuWithSuccess:(void (^)(NSArray *topics))success failure:(void (^)(NSError *error))failure +{ + NSString *path = @"read/menu"; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_3]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + // Normalize and flatten the results. + // A topic can appear in both recommended and subscribed dictionaries, + // so filter appropriately. + NSMutableArray *topics = [NSMutableArray array]; + + NSDictionary *defaults = [response dictionaryForKey:TopicMenuSectionDefaultKey]; + NSMutableDictionary *subscribed = [[response dictionaryForKey:TopicMenuSectionSubscribedKey] mutableCopy]; + NSMutableDictionary *recommended = [[response dictionaryForKey:TopicMenuSectionRecommendedKey] mutableCopy]; + NSArray *subscribedAndRecommended; + + NSMutableSet *subscribedSet = [NSMutableSet setWithArray:[subscribed allKeys]]; + NSSet *recommendedSet = [NSSet setWithArray:[recommended allKeys]]; + [subscribedSet intersectSet:recommendedSet]; + NSArray *sharedkeys = [subscribedSet allObjects]; + + if (sharedkeys) { + subscribedAndRecommended = [subscribed objectsForKeys:sharedkeys notFoundMarker:TopicNotFoundMarker]; + [subscribed removeObjectsForKeys:sharedkeys]; + [recommended removeObjectsForKeys:sharedkeys]; + } + + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[defaults allValues] subscribed:NO recommended:NO]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[subscribed allValues] subscribed:YES recommended:NO]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:[recommended allValues] subscribed:NO recommended:YES]]; + [topics addObjectsFromArray:[self normalizeMenuTopicsList:subscribedAndRecommended subscribed:YES recommended:YES]]; + + success(topics); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchFollowedSitesWithSuccess:(void(^)(NSArray *sites))success failure:(void(^)(NSError *error))failure +{ + void (^wrappedSuccess)(NSNumber *, NSArray *) = ^(NSNumber *totalSites, NSArray *sites) { + if (success) { + success(sites); + } + }; + + [self fetchFollowedSitesForPage:0 number:0 success:wrappedSuccess failure:failure]; +} + +- (void)fetchFollowedSitesForPage:(NSUInteger)page + number:(NSUInteger)number + success:(void(^)(NSNumber *totalSites, NSArray *sites))success + failure:(void(^)(NSError *error))failure +{ + NSString *path = [self pathForFollowedSitesWithPage:page number:number]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSDictionary *response = (NSDictionary *)responseObject; + NSNumber *totalSites = [response numberForKey:@"total_subscriptions"]; + NSArray *subscriptions = [response arrayForKey:@"subscriptions"]; + NSMutableArray *sites = [NSMutableArray array]; + for (NSDictionary *dict in subscriptions) { + RemoteReaderSiteInfo *siteInfo = [self siteInfoFromFollowedSiteDictionary:dict]; + [sites addObject:siteInfo]; + } + success(totalSites, sites); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)unfollowTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@/mine/delete", slug]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(NSDictionary *responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSNumber *unfollowedTag = [responseObject numberForKey:TopicRemovedTagKey]; + success(unfollowedTag); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)followTopicNamed:(NSString *)topicName + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *slug = [self slugForTopicName:topicName]; + [self followTopicWithSlug:slug withSuccess:success failure:failure]; +} + +- (void)followTopicWithSlug:(NSString *)slug + withSuccess:(void (^)(NSNumber *topicID))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@/mine/new", slug]; + path = [path stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLPathAllowedCharacterSet]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + NSNumber *followedTag = [responseObject numberForKey:TopicAddedTagKey]; + success(followedTag); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchTagInfoForTagWithSlug:(NSString *)slug + success:(void (^)(RemoteReaderTopic *remoteTopic))success + failure:(void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"read/tags/%@", slug]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + NSDictionary *response = (NSDictionary *)responseObject; + NSDictionary *topicDict = [response dictionaryForKey:TopicDictionaryTagKey]; + RemoteReaderTopic *remoteTopic = [self normalizeMenuTopicDictionary:topicDict subscribed:NO recommended:NO]; + remoteTopic.isMenuItem = NO; + success(remoteTopic); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)fetchSiteInfoForSiteWithID:(NSNumber *)siteID + isFeed:(BOOL)isFeed + success:(void (^)(RemoteReaderSiteInfo *siteInfo))success + failure:(void (^)(NSError *error))failure +{ + NSString *requestUrl; + if (isFeed) { + NSString *path = [NSString stringWithFormat:@"read/feed/%@", siteID]; + requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + } else { + NSString *path = [NSString stringWithFormat:@"read/sites/%@", siteID]; + requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + } + + [self.wordPressComRESTAPI get:requestUrl parameters:nil success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (!success) { + return; + } + + + NSDictionary *response = (NSDictionary *)responseObject; + RemoteReaderSiteInfo *siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:response + isFeed:isFeed]; + + siteInfo.postsEndpoint = [self endpointUrlForPath:siteInfo.endpointPath]; + + success(siteInfo); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (RemoteReaderSiteInfo *)siteInfoFromFollowedSiteDictionary:(NSDictionary *)dict +{ + NSDictionary *meta = [dict dictionaryForKeyPath:@"meta.data.site"]; + RemoteReaderSiteInfo *siteInfo; + + if (meta) { + siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:meta isFeed:NO]; + } else { + meta = [dict dictionaryForKeyPath:@"meta.data.feed"]; + siteInfo = [RemoteReaderSiteInfo siteInfoForSiteResponse:meta isFeed:YES]; + } + + siteInfo.postsEndpoint = [self endpointUrlForPath:siteInfo.endpointPath]; + + return siteInfo; +} + +- (NSString *)endpointUrlForPath:(NSString *)endpoint +{ + NSString *absolutePath = [self pathForEndpoint:endpoint withVersion:WordPressComRESTAPIVersion_1_2]; + NSURL *url = [NSURL URLWithString:absolutePath relativeToURL:self.wordPressComRESTAPI.baseURL]; + return [url absoluteString]; +} + + +#pragma mark - Private Methods + +/** + Formats the specified string for use as part of the URL path for the tags endpoints + in the REST API. Spaces and periods are converted to dashes, ampersands and hashes are + removed. + See https://github.com/WordPress/WordPress/blob/master/wp-includes/formatting.php#L1258 + + @param topicName The string to be formatted. + @return The formatted string. + */ +- (NSString *)slugForTopicName:(NSString *)topicName +{ + if (!topicName || [topicName length] == 0) { + return @""; + } + + static NSRegularExpression *regexHtmlEntities; + static NSRegularExpression *regexPeriodsWhitespace; + static NSRegularExpression *regexNonAlphaNumNonDash; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSError *error; + regexHtmlEntities = [NSRegularExpression regularExpressionWithPattern:@"&[^\\s]*;" options:NSRegularExpressionCaseInsensitive error:&error]; + regexPeriodsWhitespace = [NSRegularExpression regularExpressionWithPattern:@"[\\.\\s]+" options:NSRegularExpressionCaseInsensitive error:&error]; + regexNonAlphaNumNonDash = [NSRegularExpression regularExpressionWithPattern:@"[^\\p{L}\\p{Nd}\\-]+" options:NSRegularExpressionCaseInsensitive error:&error]; + }); + + topicName = [[topicName lowercaseString] trim]; + + // remove html entities + topicName = [regexHtmlEntities stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@""]; + + // replace periods and whitespace with a dash + topicName = [regexPeriodsWhitespace stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@"-"]; + + // remove remaining non-alphanum/non-dash chars + topicName = [regexNonAlphaNumNonDash stringByReplacingMatchesInString:topicName + options:NSMatchingReportProgress + range:NSMakeRange(0, [topicName length]) + withTemplate:@""]; + + // reduce double dashes potentially added above + while ([topicName rangeOfString:@"--"].location != NSNotFound) { + topicName = [topicName stringByReplacingOccurrencesOfString:@"--" withString:@"-"]; + } + + topicName = [topicName stringByRemovingPercentEncoding]; + + return topicName; +} + +- (NSArray *)normalizeMenuTopicsList:(NSArray *)rawTopics subscribed:(BOOL)subscribed recommended:(BOOL)recommended +{ + return [[rawTopics wp_filter:^BOOL(id obj) { + return [obj isKindOfClass:[NSDictionary class]]; + }] wp_map:^id(NSDictionary *topic) { + return [self normalizeMenuTopicDictionary:topic subscribed:subscribed recommended:recommended]; + }]; +} + +- (RemoteReaderTopic *)normalizeMenuTopicDictionary:(NSDictionary *)topicDict subscribed:(BOOL)subscribed recommended:(BOOL)recommended +{ + RemoteReaderTopic *topic = [[RemoteReaderTopic alloc] initWithDictionary:topicDict subscribed:subscribed recommended:recommended]; + topic.isMenuItem = YES; + return topic; +} + +- (NSString *)pathForFollowedSitesWithPage:(NSUInteger)page number:(NSUInteger)number +{ + NSString *path = @"read/following/mine?meta=site,feed"; + if (page > 0) { + path = [path stringByAppendingFormat:@"&page=%lu", (unsigned long)page]; + } + if (number > 0) { + path = [path stringByAppendingFormat:@"&number=%lu", (unsigned long)number]; + } + + return path; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/RemoteConfigRemote.swift b/WordPressKit/Sources/WordPressKit/Services/RemoteConfigRemote.swift new file mode 100644 index 000000000000..4fdc076dd53a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/RemoteConfigRemote.swift @@ -0,0 +1,37 @@ +import Foundation + +open class RemoteConfigRemote: ServiceRemoteWordPressComREST { + + public typealias RemoteConfigDictionary = [String: Any] + public typealias RemoteConfigResponseCallback = (Result) -> Void + + public enum RemoteConfigRemoteError: Error { + case InvalidDataError + } + + open func getRemoteConfig(callback: @escaping RemoteConfigResponseCallback) { + + let endpoint = "mobile/remote-config" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + if let remoteConfigDictionary = response as? [String: Any] { + callback(.success(remoteConfigDictionary)) + } else { + callback(.failure(RemoteConfigRemoteError.InvalidDataError)) + } + + }, failure: { error, response in + WPKitLogError("Error retrieving remote config values") + WPKitLogError("\(error)") + + if let response = response { + WPKitLogDebug("Response Code: \(response.statusCode)") + } + + callback(.failure(error)) + }) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.h new file mode 100644 index 000000000000..76d6a8b2bdf6 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.h @@ -0,0 +1,19 @@ +#import + +@class WordPressOrgXMLRPCApi; + +NS_ASSUME_NONNULL_BEGIN + +@interface ServiceRemoteWordPressXMLRPC : NSObject + +- (id)initWithApi:(WordPressOrgXMLRPCApi *)api username:(NSString *)username password:(NSString *)password; + +@property (nonatomic, readonly) WordPressOrgXMLRPCApi *api; + +- (NSArray *)defaultXMLRPCArguments; +- (NSArray *)XMLRPCArgumentsWithExtra:(_Nullable id)extra; +- (NSArray *)XMLRPCArgumentsWithExtraDefaults:(NSArray *)extraDefaults andExtra:(_Nullable id)extra; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.m new file mode 100644 index 000000000000..c7db137a6637 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ServiceRemoteWordPressXMLRPC.m @@ -0,0 +1,69 @@ +#import "ServiceRemoteWordPressXMLRPC.h" + +@interface ServiceRemoteWordPressXMLRPC() + +@property (nonatomic, strong, readwrite) WordPressOrgXMLRPCApi *api; +@property (nonatomic, copy) NSString *username; +@property (nonatomic, copy) NSString *password; + +@end + +@implementation ServiceRemoteWordPressXMLRPC + +- (id)initWithApi:(WordPressOrgXMLRPCApi *)api username:(NSString *)username password:(NSString *)password +{ + NSParameterAssert(api != nil); + NSParameterAssert(username != nil); + NSParameterAssert(password != nil); + if (username == nil || password == nil) { + return nil; + } + + self = [super init]; + if (self) { + _api = api; + _username = username; + _password = password; + } + return self; +} + +/** + Common XML-RPC arguments to most calls + + Most XML-RPC calls will take blog ID, username, and password as their first arguments. + Blog ID is unused since the blog is inferred from the XML-RPC endpoint. We send a value of 0 + because the documentation expects an int value, and we have to send something. + + See https://github.com/WordPress/WordPress/blob/master/wp-includes/class-wp-xmlrpc-server.php + for method documentation. + */ +- (NSArray *)defaultXMLRPCArguments { + return @[@0, self.username, self.password]; +} + +- (NSArray *)XMLRPCArgumentsWithExtra:(id)extra { + NSMutableArray *result = [[self defaultXMLRPCArguments] mutableCopy]; + if ([extra isKindOfClass:[NSArray class]]) { + [result addObjectsFromArray:extra]; + } else if (extra != nil) { + [result addObject:extra]; + } + + return [NSArray arrayWithArray:result]; +} + +- (NSArray *)XMLRPCArgumentsWithExtraDefaults:(NSArray *)extraDefaults andExtra:(_Nullable id)extra { + NSMutableArray *result = [[self defaultXMLRPCArguments] mutableCopy]; + [result addObjectsFromArray:extraDefaults]; + + if ([extra isKindOfClass:[NSArray class]]) { + [result addObjectsFromArray:extra]; + } else if (extra != nil) { + [result addObject:extra]; + } + + return [NSArray arrayWithArray:result]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ServiceRequest.swift b/WordPressKit/Sources/WordPressKit/Services/ServiceRequest.swift new file mode 100644 index 000000000000..99ea5afbb164 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ServiceRequest.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Enumeration to identify a service action +enum ServiceRequestAction: String { + case subscribe = "new" + case unsubscribe = "delete" + case update = "update" +} + +/// A protocol for a Service request +protocol ServiceRequest { + /// Returns a valid url path + var path: String { get } + + /// Returns the used API version + var apiVersion: WordPressComRESTAPIVersion { get } +} + +/// Reader Topic Service request +enum ReaderTopicServiceSubscriptionsRequest { + case notifications(siteId: Int, action: ServiceRequestAction) + case postsEmail(siteId: Int, action: ServiceRequestAction) + case comments(siteId: Int, action: ServiceRequestAction) +} + +extension ReaderTopicServiceSubscriptionsRequest: ServiceRequest { + var apiVersion: WordPressComRESTAPIVersion { + switch self { + case .notifications: return ._2_0 + case .postsEmail: return ._1_2 + case .comments: return ._1_2 + } + } + + var path: String { + switch self { + case .notifications(let siteId, let action): + return "read/sites/\(siteId)/notification-subscriptions/\(action.rawValue)/" + + case .postsEmail(let siteId, let action): + return "read/site/\(siteId)/post_email_subscriptions/\(action.rawValue)/" + + case .comments(let siteId, let action): + return "read/site/\(siteId)/comment_email_subscriptions/\(action.rawValue)/" + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/ShareAppContentServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/ShareAppContentServiceRemote.swift new file mode 100644 index 000000000000..a69e4f297d87 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ShareAppContentServiceRemote.swift @@ -0,0 +1,42 @@ +/// Encapsulates logic for fetching content to be shared by the user. +/// +open class ShareAppContentServiceRemote: ServiceRemoteWordPressComREST { + /// Fetch content to be shared by the user, based on the provided `appName`. + /// + /// - Parameters: + /// - appName: An enum that identifies the app to be shared. + /// - completion: A closure that will be called when the fetch request completes. + open func getContent(for appName: ShareAppName, completion: @escaping (Result) -> Void) { + let endpoint = "mobile/share-app-link" + let requestURLString = path(forEndpoint: endpoint, withVersion: ._2_0) + let params: [String: AnyObject] = [Constants.appNameParameterKey: appName.rawValue as AnyObject] + + Task { @MainActor in + await self.wordPressComRestApi + .perform( + .get, + URLString: requestURLString, + parameters: params, + jsonDecoder: .apiDecoder, + type: RemoteShareAppContent.self + ) + .map { $0.body } + .mapError { error -> Error in error.asNSError() } + .execute(completion) + } + } +} + +/// Defines a list of apps that can fetch share contents from the API. +public enum ShareAppName: String { + case wordpress + case jetpack +} + +// MARK: - Private Helpers + +private extension ShareAppContentServiceRemote { + struct Constants { + static let appNameParameterKey = "app" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/SharingServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/SharingServiceRemote.swift new file mode 100644 index 000000000000..55433b6fbef1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/SharingServiceRemote.swift @@ -0,0 +1,607 @@ +import Foundation +import NSObject_SafeExpectations +import WordPressShared + +/// SharingServiceRemote is responsible for wrangling the REST API calls related to +/// publiczice services, publicize connections, and keyring connections. +/// +open class SharingServiceRemote: ServiceRemoteWordPressComREST { + + // MARK: - Helper methods + + /// Returns an error message to use is the API returns an unexpected result. + /// + /// - Parameter operation: The NSHTTPURLResponse that returned the unexpected result. + /// + /// - Returns: An `NSError` object. + /// + @objc func errorForUnexpectedResponse(_ httpResponse: HTTPURLResponse?) -> NSError { + let failureReason = "The request returned an unexpected type." + let domain = "org.wordpress.sharing-management" + let code = 0 + var urlString = "unknown" + if let unwrappedURL = httpResponse?.url?.absoluteString { + urlString = unwrappedURL + } + let userInfo = [ + "requestURL": urlString, + NSLocalizedDescriptionKey: failureReason, + NSLocalizedFailureReasonErrorKey: failureReason + ] + return NSError(domain: domain, code: code, userInfo: userInfo) + } + + // MARK: - Publicize Related Methods + + /// Fetches the list of Publicize services. + /// + /// - Parameters: + /// - success: An optional success block accepting an array of `RemotePublicizeService` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getPublicizeServices(_ success: (([RemotePublicizeService]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "meta/external-services" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let params = ["type": "publicize"] + + wordPressComRESTAPI.get(path, parameters: params as [String: AnyObject]?) { responseObject, httpResponse in + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + success?(self.remotePublicizeServicesFromDictionary(responseDict)) + + } failure: { error, _ in + failure?(error as NSError) + } + } + + /// Fetches the list of Publicize services for a specified siteID. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemotePublicizeService` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + @objc open func getPublicizeServices(for siteID: NSNumber, + success: (([RemotePublicizeService]) -> Void)?, + failure: ((NSError?) -> Void)?) { + let path = path(forEndpoint: "sites/\(siteID)/external-services", withVersion: ._2_0) + let params = ["type": "publicize" as AnyObject] + + wordPressComRESTAPI.get( + path, + parameters: params, + success: { response, httpResponse in + guard let responseDict = response as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + success?(self.remotePublicizeServicesFromDictionary(responseDict)) + }, + failure: { error, _ in + failure?(error as NSError) + } + ) + } + + /// Fetches the current user's list of keyring connections. + /// + /// - Parameters: + /// - success: An optional success block accepting an array of `KeyringConnection` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getKeyringConnections(_ success: (([KeyringConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "me/keyring-connections" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let connections = responseDict.array(forKey: ConnectionDictionaryKeys.connections) ?? [] + let keyringConnections: [KeyringConnection] = connections.map { (dict) -> KeyringConnection in + let conn = KeyringConnection() + let dict = dict as AnyObject + let externalUsers = dict.array(forKey: ConnectionDictionaryKeys.additionalExternalUsers) ?? [] + conn.additionalExternalUsers = self.externalUsersForKeyringConnection(externalUsers as NSArray) + conn.dateExpires = DateUtils.date(fromISOString: dict.string(forKey: ConnectionDictionaryKeys.expires)) + conn.dateIssued = DateUtils.date(fromISOString: dict.string(forKey: ConnectionDictionaryKeys.issued)) + conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay + conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID + conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture + conn.keyringID = dict.number(forKey: ConnectionDictionaryKeys.ID) ?? conn.keyringID + conn.label = dict.string(forKey: ConnectionDictionaryKeys.label) ?? conn.label + conn.refreshURL = dict.string(forKey: ConnectionDictionaryKeys.refreshURL) ?? conn.refreshURL + conn.status = dict.string(forKey: ConnectionDictionaryKeys.status) ?? conn.status + conn.service = dict.string(forKey: ConnectionDictionaryKeys.service) ?? conn.service + conn.type = dict.string(forKey: ConnectionDictionaryKeys.type) ?? conn.type + conn.userID = dict.number(forKey: ConnectionDictionaryKeys.userID) ?? conn.userID + + return conn + } + + onSuccess(keyringConnections) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Creates KeyringConnectionExternalUser instances from the past array of + /// external user dictionaries. + /// + /// - Parameters: + /// - externalUsers: An array of NSDictionaries where each NSDictionary represents a KeyringConnectionExternalUser + /// + /// - Returns: An array of KeyringConnectionExternalUser instances. + /// + private func externalUsersForKeyringConnection(_ externalUsers: NSArray) -> [KeyringConnectionExternalUser] { + let arr: [KeyringConnectionExternalUser] = externalUsers.map { (dict) -> KeyringConnectionExternalUser in + let externalUser = KeyringConnectionExternalUser() + externalUser.externalID = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalID) ?? externalUser.externalID + externalUser.externalName = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalName) ?? externalUser.externalName + externalUser.externalProfilePicture = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? externalUser.externalProfilePicture + externalUser.externalCategory = (dict as AnyObject).string(forKey: ConnectionDictionaryKeys.externalCategory) ?? externalUser.externalCategory + + return externalUser + } + return arr + } + + /// Fetches the current user's list of Publicize connections for the specified site's ID. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemotePublicizeConnection` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getPublicizeConnections(_ siteID: NSNumber, success: (([RemotePublicizeConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let connections = responseDict.array(forKey: ConnectionDictionaryKeys.connections) ?? [] + let publicizeConnections: [RemotePublicizeConnection] = connections.compactMap { (dict) -> RemotePublicizeConnection? in + let conn = self.remotePublicizeConnectionFromDictionary(dict as! NSDictionary) + return conn + } + + onSuccess(publicizeConnections) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Create a new Publicize connection bweteen the specified blog and + /// the third-pary service represented by the keyring. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - keyringConnectionID: The ID of the third-party site's keyring connection. + /// - success: An optional success block accepting a `RemotePublicizeConnection` object. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func createPublicizeConnection(_ siteID: NSNumber, + keyringConnectionID: NSNumber, + externalUserID: String?, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError) -> Void)?) { + + let endpoint = "sites/\(siteID)/publicize-connections/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + var parameters: [String: AnyObject] = [PublicizeConnectionParams.keyringConnectionID: keyringConnectionID] + if let userID = externalUserID { + parameters[PublicizeConnectionParams.externalUserID] = userID as AnyObject? + } + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Update the shared status of the specified publicize connection + /// + /// - Parameters: + /// - connectionID: The ID of the publicize connection. + /// - externalID: The connection's externalID. Pass `nil` if the keyring + /// connection's default external ID should be used. Otherwise pass the external + /// ID of one if the keyring connection's `additionalExternalUsers`. + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updatePublicizeConnectionWithID(_ connectionID: NSNumber, + externalID: String?, + forSite siteID: NSNumber, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let externalUserID = (externalID == nil) ? "false" : externalID! + + let parameters = [ + PublicizeConnectionParams.externalUserID: externalUserID + ] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Update the shared status of the specified publicize connection + /// + /// - Parameters: + /// - connectionID: The ID of the publicize connection. + /// - shared: True if the connection is shared with all users of the blog. False otherwise. + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updatePublicizeConnectionWithID(_ connectionID: NSNumber, + shared: Bool, + forSite siteID: NSNumber, + success: ((RemotePublicizeConnection) -> Void)?, + failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = [ + PublicizeConnectionParams.shared: shared + ] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary, + let conn = self.remotePublicizeConnectionFromDictionary(responseDict) else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + onSuccess(conn) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Disconnects (deletes) the specified publicize connection + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - connectionID: The ID of the publicize connection. + /// - success: An optional success block accepting no arguments. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func deletePublicizeConnection(_ siteID: NSNumber, connectionID: NSNumber, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/publicize-connections/\(connectionID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Composees a `RemotePublicizeConnection` populated with values from the passed `NSDictionary` + /// + /// - Parameter dict: An `NSDictionary` representing a `RemotePublicizeConnection`. + /// + /// - Returns: A `RemotePublicizeConnection` object. + /// + private func remotePublicizeConnectionFromDictionary(_ dict: NSDictionary) -> RemotePublicizeConnection? { + guard let connectionID = dict.number(forKey: ConnectionDictionaryKeys.ID) else { + return nil + } + + let conn = RemotePublicizeConnection() + conn.connectionID = connectionID + conn.externalDisplay = dict.string(forKey: ConnectionDictionaryKeys.externalDisplay) ?? conn.externalDisplay + conn.externalID = dict.string(forKey: ConnectionDictionaryKeys.externalID) ?? conn.externalID + conn.externalName = dict.string(forKey: ConnectionDictionaryKeys.externalName) ?? conn.externalName + conn.externalProfilePicture = dict.string(forKey: ConnectionDictionaryKeys.externalProfilePicture) ?? conn.externalProfilePicture + conn.externalProfileURL = dict.string(forKey: ConnectionDictionaryKeys.externalProfileURL) ?? conn.externalProfileURL + conn.keyringConnectionID = dict.number(forKey: ConnectionDictionaryKeys.keyringConnectionID) ?? conn.keyringConnectionID + conn.keyringConnectionUserID = dict.number(forKey: ConnectionDictionaryKeys.keyringConnectionUserID) ?? conn.keyringConnectionUserID + conn.label = dict.string(forKey: ConnectionDictionaryKeys.label) ?? conn.label + conn.refreshURL = dict.string(forKey: ConnectionDictionaryKeys.refreshURL) ?? conn.refreshURL + conn.status = dict.string(forKey: ConnectionDictionaryKeys.status) ?? conn.status + conn.service = dict.string(forKey: ConnectionDictionaryKeys.service) ?? conn.service + + if let expirationDateAsString = dict.string(forKey: ConnectionDictionaryKeys.expires) { + conn.dateExpires = DateUtils.date(fromISOString: expirationDateAsString) + } + + if let issueDateAsString = dict.string(forKey: ConnectionDictionaryKeys.issued) { + conn.dateIssued = DateUtils.date(fromISOString: issueDateAsString) + } + + if let sharedDictNumber = dict.number(forKey: ConnectionDictionaryKeys.shared) { + conn.shared = sharedDictNumber.boolValue + } + + if let siteIDDictNumber = dict.number(forKey: ConnectionDictionaryKeys.siteID) { + conn.siteID = siteIDDictNumber + } + + if let userIDDictNumber = dict.number(forKey: ConnectionDictionaryKeys.userID) { + conn.userID = userIDDictNumber + } + + return conn + } + + // MARK: - Sharing Button Related Methods + + /// Fetches the list of sharing buttons for a blog. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: An optional success block accepting an array of `RemoteSharingButton` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func getSharingButtonsForSite(_ siteID: NSNumber, success: (([RemoteSharingButton]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/sharing-buttons" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let buttons = responseDict.array(forKey: SharingButtonsKeys.sharingButtons) as? NSArray ?? NSArray() + let sharingButtons = self.remoteSharingButtonsFromDictionary(buttons) + + onSuccess(sharingButtons) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Updates the list of sharing buttons for a blog. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - sharingButtons: The list of sharing buttons to update. Should be the full list and in the desired order. + /// - success: An optional success block accepting an array of `RemoteSharingButton` objects. + /// - failure: An optional failure block accepting an `NSError` argument. + /// + @objc open func updateSharingButtonsForSite(_ siteID: NSNumber, sharingButtons: [RemoteSharingButton], success: (([RemoteSharingButton]) -> Void)?, failure: ((NSError?) -> Void)?) { + let endpoint = "sites/\(siteID)/sharing-buttons" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let buttons = dictionariesFromRemoteSharingButtons(sharingButtons) + let parameters = [SharingButtonsKeys.sharingButtons: buttons] + + wordPressComRESTAPI.post(path, + parameters: parameters as [String: AnyObject]?, + success: { responseObject, httpResponse in + guard let onSuccess = success else { + return + } + + guard let responseDict = responseObject as? NSDictionary else { + failure?(self.errorForUnexpectedResponse(httpResponse)) + return + } + + let buttons = responseDict.array(forKey: SharingButtonsKeys.updated) as? NSArray ?? NSArray() + let sharingButtons = self.remoteSharingButtonsFromDictionary(buttons) + + onSuccess(sharingButtons) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Composees a `RemotePublicizeConnection` populated with values from the passed `NSDictionary` + /// + /// - Parameter buttons: An `NSArray` of `NSDictionary`s representing `RemoteSharingButton` objects. + /// + /// - Returns: An array of `RemoteSharingButton` objects. + /// + private func remoteSharingButtonsFromDictionary(_ buttons: NSArray) -> [RemoteSharingButton] { + var order = 0 + let sharingButtons: [RemoteSharingButton] = buttons.map { (dict) -> RemoteSharingButton in + let btn = RemoteSharingButton() + btn.buttonID = (dict as AnyObject).string(forKey: SharingButtonsKeys.buttonID) ?? btn.buttonID + btn.name = (dict as AnyObject).string(forKey: SharingButtonsKeys.name) ?? btn.name + btn.shortname = (dict as AnyObject).string(forKey: SharingButtonsKeys.shortname) ?? btn.shortname + if let customDictNumber = (dict as AnyObject).number(forKey: SharingButtonsKeys.custom) { + btn.custom = customDictNumber.boolValue + } + if let enabledDictNumber = (dict as AnyObject).number(forKey: SharingButtonsKeys.enabled) { + btn.enabled = enabledDictNumber.boolValue + } + btn.visibility = (dict as AnyObject).string(forKey: SharingButtonsKeys.visibility) ?? btn.visibility + btn.order = NSNumber(value: order) + order += 1 + + return btn + } + + return sharingButtons + } + + private func dictionariesFromRemoteSharingButtons(_ buttons: [RemoteSharingButton]) -> [NSDictionary] { + return buttons.map({ (btn) -> NSDictionary in + + let dict = NSMutableDictionary() + dict[SharingButtonsKeys.buttonID] = btn.buttonID + dict[SharingButtonsKeys.name] = btn.name + dict[SharingButtonsKeys.shortname] = btn.shortname + dict[SharingButtonsKeys.custom] = btn.custom + dict[SharingButtonsKeys.enabled] = btn.enabled + if let visibility = btn.visibility { + dict[SharingButtonsKeys.visibility] = visibility + } + + return dict + }) + } + + private func remotePublicizeServicesFromDictionary(_ dictionary: NSDictionary) -> [RemotePublicizeService] { + let responseString = dictionary.description as NSString + let services: NSDictionary = (dictionary.forKey(ServiceDictionaryKeys.services) as? NSDictionary) ?? NSDictionary() + + return services.allKeys.map { key in + let dict = (services.forKey(key) as? NSDictionary) ?? NSDictionary() + let pub = RemotePublicizeService() + + pub.connectURL = dict.string(forKey: ServiceDictionaryKeys.connectURL) ?? "" + pub.detail = dict.string(forKey: ServiceDictionaryKeys.description) ?? "" + pub.externalUsersOnly = dict.number(forKey: ServiceDictionaryKeys.externalUsersOnly)?.boolValue ?? false + pub.icon = dict.string(forKey: ServiceDictionaryKeys.icon) ?? "" + pub.serviceID = dict.string(forKey: ServiceDictionaryKeys.ID) ?? "" + pub.jetpackModuleRequired = dict.string(forKey: ServiceDictionaryKeys.jetpackModuleRequired) ?? "" + pub.jetpackSupport = dict.number(forKey: ServiceDictionaryKeys.jetpackSupport)?.boolValue ?? false + pub.label = dict.string(forKey: ServiceDictionaryKeys.label) ?? "" + pub.multipleExternalUserIDSupport = dict.number(forKey: ServiceDictionaryKeys.multipleExternalUserIDSupport)?.boolValue ?? false + pub.type = dict.string(forKey: ServiceDictionaryKeys.type) ?? "" + pub.status = dict.string(forKey: ServiceDictionaryKeys.status) ?? "" + + // We're not guarenteed to get the right order by inspecting the + // response dictionary's keys. Instead, we can check the index + // of each service in the response string. + pub.order = NSNumber(value: responseString.range(of: pub.serviceID).location) + + return pub + } + } +} + +// Keys for PublicizeService dictionaries +private struct ServiceDictionaryKeys { + static let connectURL = "connect_URL" + static let description = "description" + static let externalUsersOnly = "external_users_only" + static let ID = "ID" + static let icon = "icon" + static let jetpackModuleRequired = "jetpack_module_required" + static let jetpackSupport = "jetpack_support" + static let label = "label" + static let multipleExternalUserIDSupport = "multiple_external_user_ID_support" + static let services = "services" + static let type = "type" + static let status = "status" +} + +// Keys for both KeyringConnection and PublicizeConnection dictionaries +private struct ConnectionDictionaryKeys { + // shared keys + static let connections = "connections" + static let expires = "expires" + static let externalID = "external_ID" + static let externalName = "external_name" + static let externalDisplay = "external_display" + static let externalProfilePicture = "external_profile_picture" + static let issued = "issued" + static let ID = "ID" + static let label = "label" + static let refreshURL = "refresh_URL" + static let service = "service" + static let sites = "sites" + static let status = "status" + static let userID = "user_ID" + + // only KeyringConnections + static let additionalExternalUsers = "additional_external_users" + static let type = "type" + static let externalCategory = "external_category" + + // only PublicizeConnections + static let externalFollowerCount = "external_follower_count" + static let externalProfileURL = "external_profile_URL" + static let keyringConnectionID = "keyring_connection_ID" + static let keyringConnectionUserID = "keyring_connection_user_ID" + static let shared = "shared" + static let siteID = "site_ID" +} + +// Names of parameters passed when creating or updating a publicize connection +private struct PublicizeConnectionParams { + static let keyringConnectionID = "keyring_connection_ID" + static let externalUserID = "external_user_ID" + static let shared = "shared" +} + +// Names of parameters used in SharingButton requests +private struct SharingButtonsKeys { + static let sharingButtons = "sharing_buttons" + static let buttonID = "ID" + static let name = "name" + static let shortname = "shortname" + static let custom = "custom" + static let enabled = "enabled" + static let visibility = "visibility" + static let updated = "updated" +} diff --git a/WordPressKit/Sources/WordPressKit/Services/SiteDesignServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/SiteDesignServiceRemote.swift new file mode 100644 index 000000000000..cab54481fd60 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/SiteDesignServiceRemote.swift @@ -0,0 +1,63 @@ +import Foundation +import WordPressShared + +public struct SiteDesignRequest { + public enum TemplateGroup: String { + case stable + case beta + case singlePage = "single-page" + } + + public let parameters: [String: AnyObject] + + public init(withThumbnailSize thumbnailSize: CGSize, withGroups groups: [TemplateGroup] = []) { + var parameters: [String: AnyObject] + parameters = [ + "preview_width": "\(thumbnailSize.width)" as AnyObject, + "preview_height": "\(thumbnailSize.height)" as AnyObject, + "scale": UIScreen.main.nativeScale as AnyObject + ] + if 0 < groups.count { + let groups = groups.map { $0.rawValue } + parameters["group"] = groups.joined(separator: ",") as AnyObject + } + self.parameters = parameters + } +} + +public class SiteDesignServiceRemote { + + public typealias CompletionHandler = (Swift.Result) -> Void + + static let endpoint = "/wpcom/v2/common-starter-site-designs" + static let parameters: [String: AnyObject] = [ + "type": ("mobile" as AnyObject) + ] + + private static func joinParameters(_ parameters: [String: AnyObject], additionalParameters: [String: AnyObject]?) -> [String: AnyObject] { + guard let additionalParameters = additionalParameters else { return parameters } + return parameters.reduce(into: additionalParameters, { (result, element) in + result[element.key] = element.value + }) + } + + public static func fetchSiteDesigns(_ api: WordPressComRestApi, request: SiteDesignRequest? = nil, completion: @escaping CompletionHandler) { + let combinedParameters: [String: AnyObject] = joinParameters(parameters, additionalParameters: request?.parameters) + api.GET(endpoint, parameters: combinedParameters, success: { (responseObject, _) in + do { + let result = try parseLayouts(fromResponse: responseObject) + completion(.success(result)) + } catch let error { + NSLog("error response object: %@", String(describing: responseObject)) + completion(.failure(error)) + } + }, failure: { (error, _) in + completion(.failure(error)) + }) + } + + private static func parseLayouts(fromResponse response: Any) throws -> RemoteSiteDesigns { + let data = try JSONSerialization.data(withJSONObject: response) + return try JSONDecoder().decode(RemoteSiteDesigns.self, from: data) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/SiteManagementServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/SiteManagementServiceRemote.swift new file mode 100644 index 000000000000..989b9ef4a7e3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/SiteManagementServiceRemote.swift @@ -0,0 +1,169 @@ +import Foundation + +/// SiteManagementServiceRemote handles REST API calls for managing a WordPress.com site. +/// +open class SiteManagementServiceRemote: ServiceRemoteWordPressComREST { + /// Deletes the specified WordPress.com site. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with no parameters + /// - failure: Optional failure block with NSError + /// + @objc open func deleteSite(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/delete" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [String: AnyObject] else { + failure?(SiteError.deleteInvalidResponse.toNSError()) + return + } + guard let status = results[ResultKey.Status] as? String else { + failure?(SiteError.deleteMissingStatus.toNSError()) + return + } + guard status == ResultValue.Deleted else { + failure?(SiteError.deleteFailed.toNSError()) + return + } + + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Triggers content export of the specified WordPress.com site. + /// + /// - Note: An email will be sent with download link when export completes. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with no parameters + /// - failure: Optional failure block with NSError + /// + @objc open func exportContent(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/exports/start" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.post(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [String: AnyObject] else { + failure?(SiteError.exportInvalidResponse.toNSError()) + return + } + guard let status = results[ResultKey.Status] as? String else { + failure?(SiteError.exportMissingStatus.toNSError()) + return + } + guard status == ResultValue.Running else { + failure?(SiteError.exportFailed.toNSError()) + return + } + + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Gets the list of active purchases of the specified WordPress.com site. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block with array of purchases (if any) + /// - failure: Optional failure block with NSError + /// + @objc open func getActivePurchases(_ siteID: NSNumber, success: (([SitePurchase]) -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/purchases" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: nil, + success: { response, _ in + guard let results = response as? [SitePurchase] else { + failure?(SiteError.purchasesInvalidResponse.toNSError()) + return + } + + let actives = results.filter { $0[ResultKey.Active]?.boolValue == true } + success?(actives) + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Trigger a masterbar notification celebrating completion of mobile quick start. + /// + /// - Parameters: + /// - siteID: The WordPress.com ID of the site. + /// - success: Optional success block + /// - failure: Optional failure block with NSError + /// + @objc open func markQuickStartChecklistAsComplete(_ siteID: NSNumber, success: (() -> Void)?, failure: ((NSError) -> Void)?) { + let endpoint = "sites/\(siteID)/mobile-quick-start" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + let parameters = ["variant": "next-steps"] as [String: AnyObject] + + wordPressComRESTAPI.post(path, + parameters: parameters, + success: { _, _ in + success?() + }, + failure: { error, _ in + failure?(error as NSError) + }) + } + + /// Keys found in API results + /// + private struct ResultKey { + static let Status = "status" + static let Active = "active" + } + + /// Values found in API results + /// + private struct ResultValue { + static let Deleted = "deleted" + static let Running = "running" + } + + /// Errors generated by this class whilst parsing API results + /// + enum SiteError: Error, CustomStringConvertible { + case deleteInvalidResponse + case deleteMissingStatus + case deleteFailed + case exportInvalidResponse + case exportMissingStatus + case exportFailed + case purchasesInvalidResponse + + var description: String { + switch self { + case .deleteInvalidResponse, .deleteMissingStatus, .deleteFailed: + return NSLocalizedString("The site could not be deleted.", comment: "Message shown when site deletion API failed") + case .exportInvalidResponse, .exportMissingStatus, .exportFailed: + return NSLocalizedString("The site could not be exported.", comment: "Message shown when site export API failed") + case .purchasesInvalidResponse: + return NSLocalizedString("Could not check site purchases.", comment: "Message shown when site purchases API failed") + } + } + + func toNSError() -> NSError { + return NSError(domain: _domain, code: _code, userInfo: [NSLocalizedDescriptionKey: String(describing: self)]) + } + } +} + +/// Returned in array from /purchases endpoint +/// +public typealias SitePurchase = [String: AnyObject] diff --git a/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.h b/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.h new file mode 100644 index 000000000000..f9ddf7403d72 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.h @@ -0,0 +1,15 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SiteServiceRemoteWordPressComREST : ServiceRemoteWordPressComREST + +@property (nonatomic, readonly) NSNumber *siteID; + +- (instancetype)initWithWordPressComRestApi:(WordPressComRestApi *)api __unavailable; +- (instancetype)initWithWordPressComRestApi:(WordPressComRestApi *)api siteID:(NSNumber *)siteID; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.m b/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.m new file mode 100644 index 000000000000..c76a03b7099b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/SiteServiceRemoteWordPressComREST.m @@ -0,0 +1,17 @@ +#import "SiteServiceRemoteWordPressComREST.h" + +@interface SiteServiceRemoteWordPressComREST () +@property (nonatomic, strong) NSNumber *siteID; +@end + +@implementation SiteServiceRemoteWordPressComREST + +- (instancetype)initWithWordPressComRestApi:(WordPressComRestApi *)api siteID:(NSNumber *)siteID { + self = [super initWithWordPressComRestApi:api]; + if (self) { + _siteID = siteID; + } + return self; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift b/WordPressKit/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift new file mode 100644 index 000000000000..6cf675a63e81 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/StatsServiceRemoteV2.swift @@ -0,0 +1,451 @@ +import Foundation + +// This name isn't great! After finishing the work on StatsRefresh we'll get rid of the "old" +// one and rename this to not have "V2" in it, but we want to keep the old one around +// for a while still. + +open class StatsServiceRemoteV2: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + public enum MarkAsSpamResponseError: Error { + case unsuccessful + } + + public let siteID: Int + private let siteTimezone: TimeZone + + private var periodDataQueryDateFormatter: DateFormatter { + let df = DateFormatter() + df.locale = Locale(identifier: "en_US_POSIX") + df.dateFormat = "yyyy-MM-dd" + return df + } + + private lazy var calendarForSite: Calendar = { + var cal = Calendar(identifier: .iso8601) + cal.timeZone = siteTimezone + return cal + }() + + public init(wordPressComRestApi api: WordPressComRestApi, siteID: Int, siteTimezone: TimeZone) { + self.siteID = siteID + self.siteTimezone = siteTimezone + super.init(wordPressComRestApi: api) + } + + /// Responsible for fetching Stats data for Insights — latest data about a site, + /// in general — not considering a specific slice of time. + /// For a possible set of returned types, see objects that conform to `StatsInsightData`. + /// - parameters: + /// - limit: Limit of how many objects you want returned for your query. Default is `10`. `0` means no limit. + public func getInsight(limit: Int = 10, + completion: @escaping ((InsightType?, Error?) -> Void)) { + let properties = InsightType.queryProperties(with: limit) as [String: AnyObject] + let pathComponent = InsightType.pathComponent + + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let insight = InsightType(jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(insight, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } + + /// Used to mark or unmark referrer as spam, depending of the current value. + /// - parameters: + /// - referrerDomain: A referrer's domain. + /// - currentValue: Current value of the `isSpam` referrer's property. + open func toggleSpamState(for referrerDomain: String, + currentValue: Bool, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let path = pathForToggleSpamStateEndpoint(referrerDomain: referrerDomain, markAsSpam: !currentValue) + wordPressComRESTAPI.post(path, parameters: nil, success: { object, _ in + guard + let dictionary = object as? [String: AnyObject], + let response = MarkAsSpamResponse(dictionary: dictionary) else { + failure(ResponseError.decodingFailure) + return + } + + guard response.success else { + failure(MarkAsSpamResponseError.unsuccessful) + return + } + + success() + }, failure: { error, _ in + failure(error) + }) + } + + /// Used to fetch data about site over a specific timeframe. + /// - parameters: + /// - period: An enum representing whether either a day, a week, a month or a year worth's of data. + /// - unit: An enum representing whether the data is retuned in a day, a week, a month or a year granularity. Default is `period`. + /// - endingOn: Date on which the `period` for which data you're interested in **is ending**. + /// e.g. if you want data spanning 11-17 Feb 2019, you should pass in a period of `.week` and an + /// ending date of `Feb 17 2019`. + /// - limit: Limit of how many objects you want returned for your query. Default is `10`. `0` means no limit. + public func getData(for period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + endingOn: Date, + limit: Int = 10, + completion: @escaping ((TimeStatsType?, Error?) -> Void)) { + let pathComponent = TimeStatsType.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + + let staticProperties = ["period": period.stringValue, + "unit": unit?.stringValue ?? period.stringValue, + "date": periodDataQueryDateFormatter.string(from: endingOn)] as [String: AnyObject] + + let classProperties = TimeStatsType.queryProperties(with: endingOn, period: unit ?? period, maxCount: limit) as [String: AnyObject] + + let properties = staticProperties.merging(classProperties) { val1, _ in + return val1 + } + + wordPressComRESTAPI.get(path, parameters: properties, success: { [weak self] (response, _) in + guard + let self, + let jsonResponse = response as? [String: AnyObject], + let dateString = jsonResponse["date"] as? String, + let date = self.periodDataQueryDateFormatter.date(from: dateString) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + let periodString = jsonResponse["period"] as? String + let unitString = jsonResponse["unit"] as? String + let parsedPeriod = periodString.flatMap { StatsPeriodUnit(string: $0) } ?? period + let parsedUnit = unitString.flatMap { StatsPeriodUnit(string: $0) } ?? unit ?? period + // some responses omit this field! not a reason to fail a whole request parsing though. + + guard + let timestats = TimeStatsType(date: date, + period: parsedPeriod, + unit: parsedUnit, + jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(timestats, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } + + public func getDetails(forPostID postID: Int, completion: @escaping ((StatsPostDetails?, Error?) -> Void)) { + let path = self.path(forEndpoint: "sites/\(siteID)/stats/post/\(postID)/", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: [:], success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let postDetails = StatsPostDetails(jsonDictionary: jsonResponse) + else { + completion(nil, ResponseError.decodingFailure) + return + } + + completion(postDetails, nil) + }, failure: { (error, _) in + completion(nil, error) + }) + } +} + +// MARK: - StatsLastPostInsight Handling + +extension StatsServiceRemoteV2 { + // "Last Post" Insights are "fun" in the way that they require multiple requests to actually create them, + // so we do this "fun" dance in a separate method. + public func getInsight(limit: Int = 10, completion: @escaping ((StatsLastPostInsight?, Error?) -> Void)) { + getLastPostInsight(completion: completion) + } + + private func getLastPostInsight(limit: Int = 10, completion: @escaping ((StatsLastPostInsight?, Error?) -> Void)) { + let properties = StatsLastPostInsight.queryProperties(with: limit) as [String: AnyObject] + let pathComponent = StatsLastPostInsight.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard let jsonResponse = response as? [String: AnyObject], + let postCount = jsonResponse["found"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + + guard postCount > 0 else { + completion(nil, nil) + return + } + + guard + let posts = jsonResponse["posts"] as? [[String: AnyObject]], + let post = posts.first, + let postID = post["ID"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + + self.getPostViews(for: postID) { (views, _) in + guard + let views = views, + let insight = StatsLastPostInsight(jsonDictionary: post, views: views) else { + completion(nil, ResponseError.decodingFailure) + return + + } + + completion(insight, nil) + } + }, failure: {(error, _) in + completion(nil, error) + }) + } + + private func getPostViews(`for` postID: Int, completion: @escaping ((Int?, Error?) -> Void)) { + let parameters = ["fields": "views" as AnyObject] + + let path = self.path(forEndpoint: "sites/\(siteID)/stats/post/\(postID)", withVersion: ._1_1) + + wordPressComRESTAPI.get(path, + parameters: parameters, + success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let views = jsonResponse["views"] as? Int else { + completion(nil, ResponseError.decodingFailure) + return + } + completion(views, nil) + }, failure: { (error, _) in + completion(nil, error) + } + ) + } +} + +// MARK: - StatsPublishedPostsTimeIntervalData Handling + +extension StatsServiceRemoteV2 { + + // StatsPublishedPostsTimeIntervalData hit a different endpoint and with different parameters + // then the rest of the time-based types — we need to handle them separately here. + public func getData(for period: StatsPeriodUnit, + endingOn: Date, + limit: Int = 10, + completion: @escaping ((StatsPublishedPostsTimeIntervalData?, Error?) -> Void)) { + let pathComponent = StatsLastPostInsight.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)", withVersion: ._1_1) + + let properties = ["number": limit, + "fields": "ID, title, URL", + "after": ISO8601DateFormatter().string(from: startDate(for: period, endDate: endingOn)), + "before": ISO8601DateFormatter().string(from: endingOn)] as [String: AnyObject] + + wordPressComRESTAPI.get(path, + parameters: properties, + success: { (response, _) in + guard + let jsonResponse = response as? [String: AnyObject], + let response = StatsPublishedPostsTimeIntervalData(date: endingOn, period: period, unit: nil, jsonDictionary: jsonResponse) else { + completion(nil, ResponseError.decodingFailure) + return + } + completion(response, nil) + }, failure: { (error, _) in + completion(nil, error) + } + ) + } + + private func startDate(for period: StatsPeriodUnit, endDate: Date) -> Date { + switch period { + case .day: + return calendarForSite.startOfDay(for: endDate) + case .week: + let weekAgo = calendarForSite.date(byAdding: .day, value: -6, to: endDate)! + return calendarForSite.startOfDay(for: weekAgo) + case .month: + let monthAgo = calendarForSite.date(byAdding: .month, value: -1, to: endDate)! + let firstOfMonth = calendarForSite.date(bySetting: .day, value: 1, of: monthAgo)! + return calendarForSite.startOfDay(for: firstOfMonth) + case .year: + let yearAgo = calendarForSite.date(byAdding: .year, value: -1, to: endDate)! + let january = calendarForSite.date(bySetting: .month, value: 1, of: yearAgo)! + let jan1 = calendarForSite.date(bySetting: .day, value: 1, of: january)! + return calendarForSite.startOfDay(for: jan1) + } + } + +} + +// MARK: - Mark referrer as spam helpers + +private extension StatsServiceRemoteV2 { + func pathForToggleSpamStateEndpoint(referrerDomain: String, markAsSpam: Bool) -> String { + let action = markAsSpam ? "new" : "delete" + return self.path(forEndpoint: "sites/\(siteID)/stats/referrers/spam/\(action)?domain=\(referrerDomain)", withVersion: ._1_1) + } + + struct MarkAsSpamResponse { + let success: Bool + + init?(dictionary: [String: AnyObject]) { + guard let value = dictionary["success"] as? Bool else { + return nil + } + self.success = value + } + } +} + +// MARK: - Emails Summary + +public extension StatsServiceRemoteV2 { + func getData(quantity: Int, + sortField: StatsEmailsSummaryData.SortField = .opens, + sortOrder: StatsEmailsSummaryData.SortOrder = .descending, + completion: @escaping ((Result) -> Void)) { + let pathComponent = StatsEmailsSummaryData.pathComponent + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)/", withVersion: ._1_1) + let properties = StatsEmailsSummaryData.queryProperties(quantity: quantity, sortField: sortField, sortOrder: sortOrder) as [String: AnyObject] + + wordPressComRESTAPI.get(path, parameters: properties, success: { (response, _) in + guard let jsonResponse = response as? [String: AnyObject], + let emailsSummaryData = StatsEmailsSummaryData(jsonDictionary: jsonResponse) + else { + completion(.failure(ResponseError.decodingFailure)) + return + } + + completion(.success(emailsSummaryData)) + }, failure: { (error, _) in + completion(.failure(error)) + }) + } +} + +// This serves both as a way to get the query properties in a "nice" way, +// but also as a way to narrow down the generic type in `getInsight(completion:)` method. +public protocol StatsInsightData { + static func queryProperties(with maxCount: Int) -> [String: String] + static var pathComponent: String { get } + + init?(jsonDictionary: [String: AnyObject]) +} + +public protocol StatsTimeIntervalData { + static var pathComponent: String { get } + + var period: StatsPeriodUnit { get } + var unit: StatsPeriodUnit? { get } + var periodEndDate: Date { get } + + init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) + init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) + + static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] +} + +extension StatsTimeIntervalData { + + public var unit: StatsPeriodUnit? { + return nil + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, jsonDictionary: jsonDictionary) + } + + // Most of the responses for time data come in a unwieldy format, that requires awkwkard unwrapping + // at the call-site — unfortunately not _all of them_, which means we can't just do it at the request level. + static func unwrapDaysDictionary(jsonDictionary: [String: AnyObject]) -> [String: AnyObject]? { + guard + let days = jsonDictionary["days"] as? [String: AnyObject], + let firstKey = days.keys.first, + let firstDay = days[firstKey] as? [String: AnyObject] + else { + return nil + } + return firstDay + } + +} + +// We'll bring `StatsPeriodUnit` into this file when the "old" `WPStatsServiceRemote` gets removed. +// For now we can piggy-back off the old type and add this as an extension. +public extension StatsPeriodUnit { + var stringValue: String { + switch self { + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + } + } + + init?(string: String) { + switch string { + case "day": + self = .day + case "week": + self = .week + case "month": + self = .month + case "year": + self = .year + default: + return nil + } + } +} + +extension StatsInsightData { + + // A big chunk of those use the same endpoint and queryProperties.. Let's simplify the protocol conformance in those cases. + + public static func queryProperties(with maxCount: Int) -> [String: String] { + return ["max": String(maxCount)] + } + + public static var pathComponent: String { + return "stats/" + } +} + +public extension StatsInsightData where Self: Codable { + init?(jsonDictionary: [String: AnyObject]) { + do { + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) + let decoder = JSONDecoder() + self = try decoder.decode(Self.self, from: jsonData) + } catch { + return nil + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemote.h new file mode 100644 index 000000000000..be7466b6eacb --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemote.h @@ -0,0 +1,86 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@class RemotePostCategory; +@class RemotePostTag; +@class RemoteTaxonomyPaging; + +/** + Interface for requesting taxonomy such as tags and categories on a site. + */ +@protocol TaxonomyServiceRemote + +/** + Create a new category with the site. + */ +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *category))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories associated with the site. + Note: Requests no paging parameters via the API defaulting the response. + */ +- (void)getCategoriesWithSuccess:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories associated with the site with paging. + */ +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of categories whose names or slugs match the provided search query. Case-insensitive. + */ +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Create a new tag with the site. + */ +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Update a tag with the site. + */ +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Delete a tag with the site. + */ +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags associated with the site. + Note: Requests no paging parameters via the API defaulting the response. + */ +- (void)getTagsWithSuccess:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags associated with the site with paging. + */ +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +/** + Fetch a list of tags whose names or slugs match the provided search query. Case-insensitive. + */ +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.h b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.h new file mode 100644 index 000000000000..e3c93823624c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.h @@ -0,0 +1,7 @@ +#import +#import +#import + +@interface TaxonomyServiceRemoteREST : SiteServiceRemoteWordPressComREST + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.m b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.m new file mode 100644 index 000000000000..c35291d92ed7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteREST.m @@ -0,0 +1,358 @@ +#import "TaxonomyServiceRemoteREST.h" +#import "RemotePostTag.h" +#import "RemoteTaxonomyPaging.h" +#import "RemotePostCategory.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const TaxonomyRESTCategoryIdentifier = @"categories"; +static NSString * const TaxonomyRESTTagIdentifier = @"tags"; + +static NSString * const TaxonomyRESTIDParameter = @"ID"; +static NSString * const TaxonomyRESTNameParameter = @"name"; +static NSString * const TaxonomyRESTSlugParameter = @"slug"; +static NSString * const TaxonomyRESTDescriptionParameter = @"description"; +static NSString * const TaxonomyRESTPostCountParameter = @"post_count"; +static NSString * const TaxonomyRESTParentParameter = @"parent"; +static NSString * const TaxonomyRESTSearchParameter = @"search"; +static NSString * const TaxonomyRESTOrderParameter = @"order"; +static NSString * const TaxonomyRESTOrderByParameter = @"order_by"; +static NSString * const TaxonomyRESTNumberParameter = @"number"; +static NSString * const TaxonomyRESTOffsetParameter = @"offset"; +static NSString * const TaxonomyRESTPageParameter = @"page"; + +static NSUInteger const TaxonomyRESTNumberMaxValue = 1000; + +@implementation TaxonomyServiceRemoteREST + +#pragma mark - categories + +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *))success + failure:(nullable void (^)(NSError *))failure +{ + NSParameterAssert(category.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTNameParameter] = category.name; + if (category.parentID) { + parameters[TaxonomyRESTParentParameter] = category.parentID; + } + + [self createTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:parameters + success:^(NSDictionary *taxonomyDictionary) { + RemotePostCategory *receivedCategory = [self remoteCategoryWithJSONDictionary:taxonomyDictionary]; + if (success) { + success(receivedCategory); + } + } failure:failure]; +} + +- (void)getCategoriesWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:@{TaxonomyRESTNumberParameter: @(TaxonomyRESTNumberMaxValue)} + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(nameQuery.length > 0); + [self getTaxonomyWithType:TaxonomyRESTCategoryIdentifier + parameters:@{TaxonomyRESTSearchParameter: nameQuery} + success:^(NSDictionary *responseObject) { + success([self remoteCategoriesWithJSONArray:[responseObject arrayForKey:TaxonomyRESTCategoryIdentifier]]); + } failure:failure]; +} + +#pragma mark - tags + +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTNameParameter] = tag.name; + parameters[TaxonomyRESTDescriptionParameter] = tag.tagDescription; + + [self createTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:parameters + success:^(NSDictionary *taxonomyDictionary) { + RemotePostTag *receivedTag = [self remoteTagWithJSONDictionary:taxonomyDictionary]; + if (success) { + success(receivedTag); + } + } failure:failure]; +} + +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTSlugParameter] = tag.slug; + parameters[TaxonomyRESTNameParameter] = tag.name; + parameters[TaxonomyRESTDescriptionParameter] = tag.tagDescription; + + [self updateTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:parameters success:^(NSDictionary * _Nonnull responseObject) { + if (success) { + RemotePostTag *receivedTag = [self remoteTagWithJSONDictionary:responseObject]; + success(receivedTag); + } + } failure:failure]; +} + +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(tag.name.length > 0); + + NSMutableDictionary *parameters = [NSMutableDictionary dictionary]; + parameters[TaxonomyRESTSlugParameter] = tag.slug; + + [self deleteTaxonomyWithType:TaxonomyRESTTagIdentifier parameters:parameters success:^(NSDictionary * _Nonnull responseObject) { + if (success) { + success(); + } + } failure:failure]; +} + +- (void)getTagsWithSuccess:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:@{TaxonomyRESTNumberParameter: @(TaxonomyRESTNumberMaxValue)} + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + NSParameterAssert(nameQuery.length > 0); + [self getTaxonomyWithType:TaxonomyRESTTagIdentifier + parameters:@{TaxonomyRESTSearchParameter: nameQuery} + success:^(NSDictionary *responseObject) { + success([self remoteTagsWithJSONArray:[responseObject arrayForKey:TaxonomyRESTTagIdentifier]]); + } failure:failure]; +} + +#pragma mark - default methods + +- (void)createTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *taxonomyDictionary))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/new?context=edit", self.siteID, typeIdentifier]; + NSString *requestUrl = [self pathForEndpoint:path withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response creating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@?context=edit", self.siteID, typeIdentifier]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response requesting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/slug:%@/delete?context=edit", self.siteID, typeIdentifier, parameters[TaxonomyRESTSlugParameter]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response deleting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)updateTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSDictionary *responseObject))success + failure:(nullable void (^)(NSError *error))failure +{ + NSString *path = [NSString stringWithFormat:@"sites/%@/%@/slug:%@?context=edit", self.siteID, typeIdentifier, parameters[TaxonomyRESTSlugParameter]]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(id _Nonnull responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSDictionary class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response updating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message url:requestUrl failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError * _Nonnull error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + + + +#pragma mark - helpers + +- (NSArray *)remoteCategoriesWithJSONArray:(NSArray *)jsonArray +{ + return [jsonArray wp_map:^id(NSDictionary *jsonCategory) { + return [self remoteCategoryWithJSONDictionary:jsonCategory]; + }]; +} + +- (RemotePostCategory *)remoteCategoryWithJSONDictionary:(NSDictionary *)jsonCategory +{ + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [jsonCategory numberForKey:TaxonomyRESTIDParameter]; + category.name = [jsonCategory stringForKey:TaxonomyRESTNameParameter]; + category.parentID = [jsonCategory numberForKey:TaxonomyRESTParentParameter] ?: @0; + return category; +} + +- (NSArray *)remoteTagsWithJSONArray:(NSArray *)jsonArray +{ + return [jsonArray wp_map:^id(NSDictionary *jsonTag) { + return [self remoteTagWithJSONDictionary:jsonTag]; + }]; +} + +- (RemotePostTag *)remoteTagWithJSONDictionary:(NSDictionary *)jsonTag +{ + RemotePostTag *tag = [RemotePostTag new]; + tag.tagID = [jsonTag numberForKey:TaxonomyRESTIDParameter]; + tag.name = [jsonTag stringForKey:TaxonomyRESTNameParameter]; + tag.slug = [jsonTag stringForKey:TaxonomyRESTSlugParameter]; + tag.tagDescription = [jsonTag stringForKey:TaxonomyRESTDescriptionParameter]; + tag.postCount = [jsonTag numberForKey:TaxonomyRESTPostCountParameter]; + return tag; +} + +- (NSDictionary *)parametersForPaging:(RemoteTaxonomyPaging *)paging +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + if (paging.number) { + [dictionary setObject:paging.number forKey:TaxonomyRESTNumberParameter]; + } + if (paging.offset) { + [dictionary setObject:paging.offset forKey:TaxonomyRESTOffsetParameter]; + } + if (paging.page) { + [dictionary setObject:paging.page forKey:TaxonomyRESTPageParameter]; + } + if (paging.order == RemoteTaxonomyPagingOrderAscending) { + [dictionary setObject:@"ASC" forKey:TaxonomyRESTOrderParameter]; + } else if (paging.order == RemoteTaxonomyPagingOrderDescending) { + [dictionary setObject:@"DESC" forKey:TaxonomyRESTOrderParameter]; + } + if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByName) { + [dictionary setObject:@"name" forKey:TaxonomyRESTOrderByParameter]; + } else if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByCount) { + [dictionary setObject:@"count" forKey:TaxonomyRESTOrderByParameter]; + } + return dictionary.count ? dictionary : nil; +} + +- (void)handleResponseErrorWithMessage:(NSString *)message + url:(NSString *)urlStr + failure:(nullable void(^)(NSError *error))failure +{ + WPKitLogError(@"%@ - URL: %@", message, urlStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.h b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.h new file mode 100644 index 000000000000..cf7f3a77cb63 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.h @@ -0,0 +1,9 @@ +#import +#import +#import + +@class RemoteCategory; + +@interface TaxonomyServiceRemoteXMLRPC : ServiceRemoteWordPressXMLRPC + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.m b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.m new file mode 100644 index 000000000000..9bf54e74eef3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TaxonomyServiceRemoteXMLRPC.m @@ -0,0 +1,357 @@ +#import "TaxonomyServiceRemoteXMLRPC.h" +#import "RemotePostTag.h" +#import "RemoteTaxonomyPaging.h" +#import "WPKit-Swift.h" +@import WordPressShared; +@import NSObject_SafeExpectations; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const TaxonomyXMLRPCCategoryIdentifier = @"category"; +static NSString * const TaxonomyXMLRPCTagIdentifier = @"post_tag"; + +static NSString * const TaxonomyXMLRPCIDParameter = @"term_id"; +static NSString * const TaxonomyXMLRPCSlugParameter = @"slug"; +static NSString * const TaxonomyXMLRPCNameParameter = @"name"; +static NSString * const TaxonomyXMLRPCDescriptionParameter = @"description"; +static NSString * const TaxonomyXMLRPCParentParameter = @"parent"; +static NSString * const TaxonomyXMLRPCSearchParameter = @"search"; +static NSString * const TaxonomyXMLRPCOrderParameter = @"order"; +static NSString * const TaxonomyXMLRPCOrderByParameter = @"order_by"; +static NSString * const TaxonomyXMLRPCNumberParameter = @"number"; +static NSString * const TaxonomyXMLRPCOffsetParameter = @"offset"; + + +@implementation TaxonomyServiceRemoteXMLRPC + +#pragma mark - categories + +- (void)createCategory:(RemotePostCategory *)category + success:(nullable void (^)(RemotePostCategory *))success + failure:(nullable void (^)(NSError *))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:category.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + if ([category.parentID integerValue] > 0) { + [extraParameters setObject:category.parentID forKey:TaxonomyXMLRPCParentParameter]; + } + + [self createTaxonomyWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:extraParameters + success:^(NSString *responseString) { + RemotePostCategory *newCategory = [RemotePostCategory new]; + NSString *categoryID = responseString; + newCategory.categoryID = [categoryID numericValue]; + if (success) { + success(newCategory); + } + } failure:failure]; +} + +- (void)getCategoriesWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:nil + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)getCategoriesWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *categories))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)searchCategoriesWithName:(NSString *)nameQuery + success:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + NSDictionary *searchParameters = @{TaxonomyXMLRPCSearchParameter: nameQuery}; + [self getTaxonomiesWithType:TaxonomyXMLRPCCategoryIdentifier + parameters:searchParameters + success:^(NSArray *responseArray) { + success([self remoteCategoriesFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +#pragma mark - tags + +- (void)createTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:tag.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + [extraParameters setObject:tag.tagDescription ?: [NSNull null] forKey:TaxonomyXMLRPCDescriptionParameter]; + + [self createTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + parameters:extraParameters + success:^(NSString *responseString) { + RemotePostTag *newTag = [RemotePostTag new]; + NSString *tagID = responseString; + newTag.tagID = [tagID numericValue]; + newTag.name = tag.name; + newTag.tagDescription = tag.tagDescription; + newTag.slug = tag.slug; + if (success) { + success(newTag); + } + } failure:failure]; +} + +- (void)updateTag:(RemotePostTag *)tag + success:(nullable void (^)(RemotePostTag *tag))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *extraParameters = [NSMutableDictionary dictionary]; + [extraParameters setObject:tag.name ?: [NSNull null] forKey:TaxonomyXMLRPCNameParameter]; + [extraParameters setObject:tag.tagDescription ?: [NSNull null] forKey:TaxonomyXMLRPCDescriptionParameter]; + + [self editTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + termId:tag.tagID + parameters:extraParameters success:^(BOOL response) { + if (success) { + success(tag); + } + } failure:failure]; +} + +- (void)deleteTag:(RemotePostTag *)tag + success:(nullable void (^)(void))success + failure:(nullable void (^)(NSError *error))failure +{ + [self deleteTaxonomyWithType:TaxonomyXMLRPCTagIdentifier + termId:tag.tagID + parameters:nil success:^(BOOL response) { + if (success) { + success(); + } + } failure:failure]; +} + +- (void)getTagsWithSuccess:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:nil + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)getTagsWithPaging:(RemoteTaxonomyPaging *)paging + success:(void (^)(NSArray *tags))success + failure:(nullable void (^)(NSError *error))failure +{ + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:[self parametersForPaging:paging] + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +- (void)searchTagsWithName:(NSString *)nameQuery + success:(void (^)(NSArray *))success + failure:(nullable void (^)(NSError *))failure +{ + NSDictionary *searchParameters = @{TaxonomyXMLRPCSearchParameter: nameQuery}; + [self getTaxonomiesWithType:TaxonomyXMLRPCTagIdentifier + parameters:searchParameters + success:^(NSArray *responseArray) { + success([self remoteTagsFromXMLRPCArray:responseArray]); + } failure:failure]; +} + +#pragma mark - default methods + +- (void)createTaxonomyWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSString *responseString))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *mutableParametersDict = [NSMutableDictionary dictionaryWithDictionary:@{@"taxonomy": typeIdentifier}]; + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + [mutableParametersDict addEntriesFromDictionary:parameters]; + } + + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:mutableParametersDict]; + + [self.api callMethod:@"wp.newTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(numericValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response creating taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.newTerm" failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)getTaxonomiesWithType:(NSString *)typeIdentifier + parameters:(nullable NSDictionary *)parameters + success:(void (^)(NSArray *responseArray))success + failure:(nullable void (^)(NSError *error))failure +{ + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:@[typeIdentifier, parameters]]; + }else { + xmlrpcParameters = [self XMLRPCArgumentsWithExtra:typeIdentifier]; + } + [self.api callMethod:@"wp.getTerms" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject isKindOfClass:[NSArray class]]) { + NSString *message = [NSString stringWithFormat:@"Invalid response requesting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.getTerms" failure:failure]; + return; + } + success(responseObject); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)deleteTaxonomyWithType:(NSString *)typeIdentifier + termId:(NSNumber *)termId + parameters:(nullable NSDictionary *)parameters + success:(void (^)(BOOL response))success + failure:(nullable void (^)(NSError *error))failure +{ + NSArray *xmlrpcParameters = [self XMLRPCArgumentsWithExtraDefaults:@[typeIdentifier, termId] + andExtra:nil]; + + [self.api callMethod:@"wp.deleteTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(boolValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response deleting taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.deleteTerm" failure:failure]; + return; + } + success([responseObject boolValue]); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +- (void)editTaxonomyWithType:(NSString *)typeIdentifier + termId:(NSNumber *)termId + parameters:(nullable NSDictionary *)parameters + success:(void (^)(BOOL response))success + failure:(nullable void (^)(NSError *error))failure +{ + NSMutableDictionary *mutableParametersDict = [NSMutableDictionary dictionaryWithDictionary:@{@"taxonomy": typeIdentifier}]; + NSArray *xmlrpcParameters = nil; + if (parameters.count) { + [mutableParametersDict addEntriesFromDictionary:parameters]; + } + + xmlrpcParameters = [self XMLRPCArgumentsWithExtraDefaults:@[termId] andExtra:mutableParametersDict]; + + [self.api callMethod:@"wp.editTerm" + parameters:xmlrpcParameters + success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + if (![responseObject respondsToSelector:@selector(boolValue)]) { + NSString *message = [NSString stringWithFormat:@"Invalid response editing taxonomy of type: %@", typeIdentifier]; + [self handleResponseErrorWithMessage:message method:@"wp.editTerm" failure:failure]; + return; + } + success([responseObject boolValue]); + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - helpers + +- (NSArray *)remoteCategoriesFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wp_map:^id(NSDictionary *xmlrpcCategory) { + return [self remoteCategoryFromXMLRPCDictionary:xmlrpcCategory]; + }]; +} + +- (RemotePostCategory *)remoteCategoryFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemotePostCategory *category = [RemotePostCategory new]; + category.categoryID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCIDParameter]; + category.name = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCNameParameter]; + category.parentID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCParentParameter]; + return category; +} + +- (NSArray *)remoteTagsFromXMLRPCArray:(NSArray *)xmlrpcArray +{ + return [xmlrpcArray wp_map:^id(NSDictionary *xmlrpcTag) { + return [self remoteTagFromXMLRPCDictionary:xmlrpcTag]; + }]; +} + +- (RemotePostTag *)remoteTagFromXMLRPCDictionary:(NSDictionary *)xmlrpcDictionary +{ + RemotePostTag *tag = [RemotePostTag new]; + tag.tagID = [xmlrpcDictionary numberForKey:TaxonomyXMLRPCIDParameter]; + tag.name = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCNameParameter]; + tag.slug = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCSlugParameter]; + tag.tagDescription = [xmlrpcDictionary stringForKey:TaxonomyXMLRPCDescriptionParameter]; + return tag; +} + +- (NSDictionary *)parametersForPaging:(RemoteTaxonomyPaging *)paging +{ + NSMutableDictionary *dictionary = [NSMutableDictionary dictionary]; + if (paging.number) { + [dictionary setObject:paging.number forKey:TaxonomyXMLRPCNumberParameter]; + } + if (paging.offset) { + [dictionary setObject:paging.offset forKey:TaxonomyXMLRPCOffsetParameter]; + } + if (paging.order == RemoteTaxonomyPagingOrderAscending) { + [dictionary setObject:@"ASC" forKey:TaxonomyXMLRPCOrderParameter]; + } else if (paging.order == RemoteTaxonomyPagingOrderDescending) { + [dictionary setObject:@"DESC" forKey:TaxonomyXMLRPCOrderParameter]; + } + if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByName) { + [dictionary setObject:@"name" forKey:TaxonomyXMLRPCOrderByParameter]; + } else if (paging.orderBy == RemoteTaxonomyPagingResultsOrderingByCount) { + [dictionary setObject:@"count" forKey:TaxonomyXMLRPCOrderByParameter]; + } + return dictionary.count ? dictionary : nil; +} + +- (void)handleResponseErrorWithMessage:(NSString *)message + method:(NSString *)methodStr + failure:(nullable void(^)(NSError *error))failure +{ + WPKitLogError(@"%@ - method: %@", message, methodStr); + NSError *error = [NSError errorWithDomain:NSURLErrorDomain + code:NSURLErrorBadServerResponse + userInfo:@{NSLocalizedDescriptionKey: message}]; + if (failure) { + failure(error); + } +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.h new file mode 100644 index 000000000000..3ee51c4af3d1 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.h @@ -0,0 +1,156 @@ +#import +#import + +@class Blog; +@class RemoteTheme; + +typedef void(^ThemeServiceRemoteSuccessBlock)(void); +typedef void(^ThemeServiceRemoteThemeRequestSuccessBlock)(RemoteTheme *theme); +typedef void(^ThemeServiceRemoteThemesRequestSuccessBlock)(NSArray *themes, BOOL hasMore, NSInteger totalThemeCount); +typedef void(^ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)(NSArray *themeIdentifiers); +typedef void(^ThemeServiceRemoteFailureBlock)(NSError *error); + +@interface ThemeServiceRemote : ServiceRemoteWordPressComREST + +#pragma mark - Getting themes + +/** + * @brief Gets the active theme for a specific blog. + * + * @param blogId The ID of the blog to get the active theme for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getActiveThemeForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of purchased-theme-identifiers for a blog. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getPurchasedThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets information for a specific theme. + * + * @param themeId The identifier of the theme to request info for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getThemeId:(NSString*)themeId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of WP.com available themes. + * @details Includes premium themes even if not purchased. Don't call this method if the list + * you want to retrieve is for a specific blog. Use getThemesForBlogId instead. + * + * @param freeOnly Only fetch free themes, if false all WP themes will be returned + * @param page Results page to return. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getWPThemesPage:(NSInteger)page + freeOnly:(BOOL)freeOnly + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of available themes for a blog. + * @details Includes premium themes even if not purchased. The only difference with the + * regular getThemes method is that legacy themes that are no longer available to new + * blogs, can be accessible for older blogs through this call. This means that + * whenever we need to show the list of themes a blog can use, we should be calling + * this method and not getThemes. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param page Results page to return. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets the list of available custom themes for a blog. + * @details To be used with Jetpack sites, it returns the list of themes uploaded to the site. + * + * @param blogId The ID of the blog to get the themes for. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)getCustomThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +/** + * @brief Gets a list of suggested starter themes for the given site category + * (blog, website, portfolio). + * @details During the site creation process, a list of suggested mobile-friendly starter + * themes is displayed for the selected category. + * + * @param category The category for the site being created. Cannot be nil. + * @param page Results page to return. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + */ +- (void)getStartingThemesForCategory:(NSString *)category + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +#pragma mark - Activating themes + +/** + * @brief Activates the specified theme for the specified blog. + * + * @param themeId The ID of the theme to activate. Cannot be nil. + * @param blogId The ID of the target blog. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)activateThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + + +/** + * @brief Installs the specified theme on the specified Jetpack blog. + * + * @param themeId The ID of the theme to install. Cannot be nil. + * @param blogId The ID of the target blog. Cannot be nil. + * @param success The success handler. Can be nil. + * @param failure The failure handler. Can be nil. + * + * @returns A progress object that can be used to track progress and/or cancel the task + */ +- (NSProgress *)installThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.m new file mode 100644 index 000000000000..97d6e60e048c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/ThemeServiceRemote.m @@ -0,0 +1,465 @@ +#import "ThemeServiceRemote.h" + +#import "RemoteTheme.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; + +// Service dictionary keys +static NSString* const ThemeServiceRemoteThemesKey = @"themes"; +static NSString* const ThemeServiceRemoteThemeCountKey = @"found"; +static NSString* const ThemeRequestTierKey = @"tier"; +static NSString* const ThemeRequestTierAllValue = @"all"; +static NSString* const ThemeRequestTierFreeValue = @"free"; +static NSString* const ThemeRequestNumberKey = @"number"; +static NSInteger const ThemeRequestNumberValue = 50; +static NSString* const ThemeRequestPageKey = @"page"; + +@implementation ThemeServiceRemote + +#pragma mark - Getting themes + +- (NSProgress *)getActiveThemeForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes/mine", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + theme.active = YES; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getPurchasedThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeIdentifiersRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes/purchased", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *themes = [self themeIdentifiersFromPurchasedThemesRequestResponse:response]; + success(themes); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getThemeId:(NSString*)themeId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"themes/%@", themeId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI get:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)getWPThemesPage:(NSInteger)page + freeOnly:(BOOL)freeOnly + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + + NSString *requestUrl = [self pathForEndpoint:@"themes" + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ThemeRequestTierKey: freeOnly ? ThemeRequestTierFreeValue : ThemeRequestTierAllValue, + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page), + }; + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesPage:(NSInteger)page + path:(NSString *)path + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + NSParameterAssert([path isKindOfClass:[NSString class]]); + + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ThemeRequestTierKey: ThemeRequestTierAllValue, + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page), + }; + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + NSParameterAssert(page > 0); + + NSProgress *progress = [self getThemesForBlogId:blogId + page:page + apiVersion:WordPressComRESTAPIVersion_1_2 + params:@{ThemeRequestTierKey: ThemeRequestTierAllValue} + success:success + failure:failure]; + + return progress; +} + +- (NSProgress *)getCustomThemesForBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSProgress *progress = [self getThemesForBlogId:blogId + page:1 + apiVersion:WordPressComRESTAPIVersion_1_0 + params:@{} + success:success + failure:failure]; + + return progress; +} + +- (NSProgress *)getThemesForBlogId:(NSNumber *)blogId + page:(NSInteger)page + apiVersion:(WordPressComRESTAPIVersion) apiVersion + params:(NSDictionary *)params + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + NSParameterAssert(page > 0); + + NSString *path = [NSString stringWithFormat:@"sites/%@/themes", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:apiVersion]; + + NSMutableDictionary *parameters = [params mutableCopy]; + parameters[ThemeRequestNumberKey] = @(ThemeRequestNumberValue); + parameters[ThemeRequestPageKey] = @(page); + + return [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (void)getStartingThemesForCategory:(NSString *)category + page:(NSInteger)page + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert(page > 0); + NSParameterAssert([category isKindOfClass:[NSString class]]); + + NSString *path = [NSString stringWithFormat:@"themes/?filter=starting-%@", category]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_2]; + + NSDictionary *parameters = @{ + ThemeRequestNumberKey: @(ThemeRequestNumberValue), + ThemeRequestPageKey: @(page), + }; + + [self getThemesWithRequestUrl:requestUrl + page:page + parameters:parameters + success:success + failure:failure]; +} + +- (NSProgress *)getThemesWithRequestUrl:(NSString *)requestUrl + page:(NSInteger)page + parameters:(NSDictionary *)parameters + success:(ThemeServiceRemoteThemesRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + + return [self.wordPressComRESTAPI get:requestUrl + parameters:parameters + success:^(NSDictionary *response, NSHTTPURLResponse *httpResponse) { + if (success) { + NSArray *themes = [self themesFromMultipleThemesRequestResponse:response]; + NSInteger themesLoaded = (page - 1) * ThemeRequestNumberValue; + for (RemoteTheme *theme in themes){ + theme.order = ++themesLoaded; + } + // v1 of the API does not return the found field + NSInteger themesCount = MAX(themes.count, [[response numberForKey:ThemeServiceRemoteThemeCountKey] integerValue]); + BOOL hasMore = themesLoaded < themesCount; + success(themes, hasMore, themesCount); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; +} + +#pragma mark - Activating themes + +- (NSProgress *)activateThemeId:(NSString *)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString* const path = [NSString stringWithFormat:@"sites/%@/themes/mine", blogId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSDictionary* parameters = @{@"theme": themeId}; + + NSProgress *progress = [self.wordPressComRESTAPI post:requestUrl + parameters:parameters + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + theme.active = YES; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + + return progress; +} + +- (NSProgress *)installThemeId:(NSString*)themeId + forBlogId:(NSNumber *)blogId + success:(ThemeServiceRemoteThemeRequestSuccessBlock)success + failure:(ThemeServiceRemoteFailureBlock)failure +{ + NSParameterAssert([themeId isKindOfClass:[NSString class]]); + NSParameterAssert([blogId isKindOfClass:[NSNumber class]]); + + NSString* const path = [NSString stringWithFormat:@"sites/%@/themes/%@/install", blogId, themeId]; + NSString *requestUrl = [self pathForEndpoint:path + withVersion:WordPressComRESTAPIVersion_1_1]; + + NSProgress *progress = [self.wordPressComRESTAPI post:requestUrl + parameters:nil + success:^(NSDictionary *themeDictionary, NSHTTPURLResponse *httpResponse) { + if (success) { + RemoteTheme *theme = [self themeFromDictionary:themeDictionary]; + success(theme); + } + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + if (failure) { + failure(error); + } + }]; + return progress; +} + +#pragma mark - Parsing responses + +/** + * @brief Parses a purchased-themes-request response. + * + * @param response The response object. Cannot be nil. + */ +- (NSArray *)themeIdentifiersFromPurchasedThemesRequestResponse:(id)response +{ + NSParameterAssert(response != nil); + + NSArray *themeIdentifiers = [response arrayForKey:ThemeServiceRemoteThemesKey]; + + return themeIdentifiers; +} + +/** + * @brief Parses a generic multi-themes-request response. + * + * @param response The response object. Cannot be nil. + */ +- (NSArray *)themesFromMultipleThemesRequestResponse:(id)response +{ + NSParameterAssert(response != nil); + + NSArray *themeDictionaries = [response arrayForKey:ThemeServiceRemoteThemesKey]; + NSArray *themes = [self themesFromDictionaries:themeDictionaries]; + + return themes; +} + +#pragma mark - Parsing the dictionary replies + +/** + * @brief Creates a remote theme object from the specified dictionary. + * + * @param dictionary The dictionary containing the theme information. Cannot be nil. + * + * @returns A remote theme object. + */ +- (RemoteTheme *)themeFromDictionary:(NSDictionary *)dictionary +{ + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + + static NSString* const ThemeActiveKey = @"active"; + static NSString* const ThemeTypeKey = @"theme_type"; + static NSString* const ThemeAuthorKey = @"author"; + static NSString* const ThemeAuthorURLKey = @"author_uri"; + static NSString* const ThemeCostPath = @"cost.number"; + static NSString* const ThemeDemoURLKey = @"demo_uri"; + static NSString* const ThemeURL = @"theme_uri"; + static NSString* const ThemeDescriptionKey = @"description"; + static NSString* const ThemeDownloadURLKey = @"download_uri"; + static NSString* const ThemeIdKey = @"id"; + static NSString* const ThemeNameKey = @"name"; + static NSString* const ThemePreviewURLKey = @"preview_url"; + static NSString* const ThemePriceKey = @"price"; + static NSString* const ThemePurchasedKey = @"purchased"; + static NSString* const ThemePopularityRankKey = @"rank_popularity"; + static NSString* const ThemeScreenshotKey = @"screenshot"; + static NSString* const ThemeStylesheetKey = @"stylesheet"; + static NSString* const ThemeTrendingRankKey = @"rank_trending"; + static NSString* const ThemeVersionKey = @"version"; + static NSString* const ThemeDomainPublic = @"pub"; + static NSString* const ThemeDomainPremium = @"premium"; + + RemoteTheme *theme = [RemoteTheme new]; + + [self loadLaunchDateForTheme:theme fromDictionary:dictionary]; + + theme.active = [[dictionary numberForKey:ThemeActiveKey] boolValue]; + theme.type = [dictionary stringForKey:ThemeTypeKey]; + theme.author = [dictionary stringForKey:ThemeAuthorKey]; + theme.authorUrl = [dictionary stringForKey:ThemeAuthorURLKey]; + theme.demoUrl = [dictionary stringForKey:ThemeDemoURLKey]; + theme.themeUrl = [dictionary stringForKey:ThemeURL]; + theme.desc = [dictionary stringForKey:ThemeDescriptionKey]; + theme.downloadUrl = [dictionary stringForKey:ThemeDownloadURLKey]; + theme.name = [dictionary stringForKey:ThemeNameKey]; + theme.popularityRank = [dictionary numberForKey:ThemePopularityRankKey]; + theme.previewUrl = [dictionary stringForKey:ThemePreviewURLKey]; + theme.price = [dictionary stringForKey:ThemePriceKey]; + theme.purchased = [dictionary numberForKey:ThemePurchasedKey]; + theme.screenshotUrl = [dictionary stringForKey:ThemeScreenshotKey]; + theme.stylesheet = [dictionary stringForKey:ThemeStylesheetKey]; + theme.themeId = [dictionary stringForKey:ThemeIdKey]; + theme.trendingRank = [dictionary numberForKey:ThemeTrendingRankKey]; + theme.version = [dictionary stringForKey:ThemeVersionKey]; + + if (!theme.stylesheet) { + NSString *domain = [dictionary numberForKeyPath:ThemeCostPath].intValue > 0 ? ThemeDomainPremium : ThemeDomainPublic; + theme.stylesheet = [NSString stringWithFormat:@"%@/%@", domain, theme.themeId]; + } + + return theme; +} + +/** + * @brief Creates remote theme objects from the specified array of dictionaries. + * + * @param dictionaries The array of dictionaries containing the themes information. Cannot + * be nil. + * + * @returns An array of remote theme objects. + */ +- (NSArray *)themesFromDictionaries:(NSArray *)dictionaries +{ + NSParameterAssert([dictionaries isKindOfClass:[NSArray class]]); + + NSMutableArray *themes = [[NSMutableArray alloc] initWithCapacity:dictionaries.count]; + + for (NSDictionary *dictionary in dictionaries) { + NSAssert([dictionary isKindOfClass:[NSDictionary class]], + @"Expected a dictionary."); + + RemoteTheme *theme = [self themeFromDictionary:dictionary]; + + [themes addObject:theme]; + } + + return [NSArray arrayWithArray:themes]; +} + +#pragma mark - Field parsing + +/** + * @brief Loads a theme's launch date from a dictionary into the specified remote theme + * object. + * + * @param theme The theme to load the info into. Cannot be nil. + * @param dictionary The dictionary to load the info from. Cannot be nil. + */ +- (void)loadLaunchDateForTheme:(RemoteTheme *)theme + fromDictionary:(NSDictionary *)dictionary +{ + NSParameterAssert([theme isKindOfClass:[RemoteTheme class]]); + NSParameterAssert([dictionary isKindOfClass:[NSDictionary class]]); + + static NSString* const ThemeLaunchDateKey = @"date_launched"; + + NSString *launchDateString = [dictionary stringForKey:ThemeLaunchDateKey]; + + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + [formatter setDateFormat:@"yyyy-mm-dd"]; + + theme.launchDate = [formatter dateFromString:launchDateString]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/TimeZoneServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/TimeZoneServiceRemote.swift new file mode 100644 index 000000000000..42e4239fa63f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TimeZoneServiceRemote.swift @@ -0,0 +1,60 @@ +import Foundation +import WordPressShared + +public class TimeZoneServiceRemote: ServiceRemoteWordPressComREST { + public enum ResponseError: Error { + case decodingFailed + } + + public func getTimezones(success: @escaping (([TimeZoneGroup]) -> Void), failure: @escaping ((Error) -> Void)) { + let endpoint = "timezones" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + wordPressComRESTAPI.get(path, parameters: nil, success: { (response, _) in + do { + let groups = try self.timezoneGroupsFromResponse(response) + success(groups) + } catch { + failure(error) + } + }) { (error, _) in + failure(error) + } + } +} + +private extension TimeZoneServiceRemote { + func timezoneGroupsFromResponse(_ response: Any) throws -> [TimeZoneGroup] { + guard let response = response as? [String: Any], + let timeZonesByContinent = response["timezones_by_continent"] as? [String: [[String: String]]], + let manualUTCOffsets = response["manual_utc_offsets"] as? [[String: String]] else { + throw ResponseError.decodingFailed + } + let continentGroups: [TimeZoneGroup] = try timeZonesByContinent.map({ + let (groupName, rawZones) = $0 + let zones = try rawZones.map({ try parseNamedTimezone(response: $0) }) + return TimeZoneGroup(name: groupName, timezones: zones) + }).sorted(by: { return $0.name < $1.name }) + + let utcOffsets: [WPTimeZone] = try manualUTCOffsets.map({ try parseOffsetTimezone(response: $0) }) + let utcOffsetsGroup = TimeZoneGroup( + name: NSLocalizedString("Manual Offsets", comment: "Section name for manual offsets in time zone selector"), + timezones: utcOffsets) + return continentGroups + [utcOffsetsGroup] + } + + func parseNamedTimezone(response: [String: String]) throws -> WPTimeZone { + guard let label = response["label"], + let value = response["value"] else { + throw ResponseError.decodingFailed + } + return NamedTimeZone(label: label, value: value) + } + + func parseOffsetTimezone(response: [String: String]) throws -> WPTimeZone { + guard let value = response["value"], + let zone = OffsetTimeZone.fromValue(value) else { + throw ResponseError.decodingFailed + } + return zone + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/TransactionsServiceRemote.swift b/WordPressKit/Sources/WordPressKit/Services/TransactionsServiceRemote.swift new file mode 100644 index 000000000000..2ba8bf85fcff --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/TransactionsServiceRemote.swift @@ -0,0 +1,210 @@ +import Foundation +import WordPressShared + +@objc public class TransactionsServiceRemote: ServiceRemoteWordPressComREST { + + public enum ResponseError: Error { + case decodingFailure + } + + private enum Constants { + static let freeDomainPaymentMethod = "WPCOM_Billing_WPCOM" + } + + @objc public func getSupportedCountries(success: @escaping ([WPCountry]) -> Void, + failure: @escaping (Error) -> Void) { + let endPoint = "me/transactions/supported-countries/" + let servicePath = path(forEndpoint: endPoint, withVersion: ._1_1) + + wordPressComRESTAPI.get(servicePath, + parameters: nil, + success: { + response, _ in + do { + guard let json = response as? [AnyObject] else { + throw ResponseError.decodingFailure + } + let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) + let decodedResult = try JSONDecoder.apiDecoder.decode([WPCountry].self, from: data) + success(decodedResult) + } catch { + WPKitLogError("Error parsing Supported Countries (\(error)): \(response)") + failure(error) + } + }, failure: { error, _ in + failure(error) + }) + } + + /// Creates a shopping cart with products + /// - Parameters: + /// - siteID: id of the current site + /// - products: an array of products to be added to the newly created cart + /// - temporary: true if the card is temporary, false otherwise + public func createShoppingCart(siteID: Int?, + products: [TransactionsServiceProduct], + temporary: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + let siteIDString = siteID != nil ? "\(siteID ?? 0)" : "no-site" + let endPoint = "me/shopping-cart/\(siteIDString)" + let urlPath = path(forEndpoint: endPoint, withVersion: ._1_1) + + var productsDictionary: [[String: AnyObject]] = [] + + for product in products { + switch product { + case .domain(let domainSuggestion, let privacyProtectionEnabled): + productsDictionary.append(["product_id": domainSuggestion.productID as AnyObject, + "meta": domainSuggestion.domainName as AnyObject, + "extra": ["privacy": privacyProtectionEnabled] as AnyObject]) + + case .plan(let productId): + productsDictionary.append(["product_id": productId as AnyObject]) + case .other(let productDict): + productsDictionary.append(productDict) + } + } + + let parameters: [String: AnyObject] = ["temporary": (temporary ? "true" : "false") as AnyObject, + "products": productsDictionary as AnyObject] + + wordPressComRESTAPI.post(urlPath, + parameters: parameters, + success: { (response, _) in + + guard let jsonResponse = response as? [String: AnyObject], + let cart = CartResponse(jsonDictionary: jsonResponse), + !cart.products.isEmpty else { + + failure(TransactionsServiceRemote.ResponseError.decodingFailure) + return + } + + success(cart) + }) { (error, _) in + failure(error) + } + } + + // MARK: - Domains + + public func redeemCartUsingCredits(cart: CartResponse, + domainContactInformation: [String: String], + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + + let endPoint = "me/transactions" + + let urlPath = path(forEndpoint: endPoint, withVersion: ._1_1) + + let paymentDict = ["payment_method": Constants.freeDomainPaymentMethod] + + let parameters: [String: AnyObject] = ["domain_details": domainContactInformation as AnyObject, + "cart": cart.jsonRepresentation() as AnyObject, + "payment": paymentDict as AnyObject] + + wordPressComRESTAPI.post(urlPath, parameters: parameters, success: { (_, _) in + success() + }) { (error, _) in + failure(error) + } + } + + /// Creates a temporary shopping cart for a domain purchase + @available(*, deprecated, message: "Use createShoppingCart(_:) and pass an array of specific products instead") + public func createTemporaryDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + createShoppingCart(siteID: siteID, + products: [.domain(domainSuggestion, privacyProtectionEnabled)], + temporary: true, + success: success, + failure: failure) + } + + /// Creates a persistent shopping cart for a domain purchase + @available(*, deprecated, message: "Use createShoppingCart(_:) and pass an array of specific products instead") + public func createPersistentDomainShoppingCart(siteID: Int, + domainSuggestion: DomainSuggestion, + privacyProtectionEnabled: Bool, + success: @escaping (CartResponse) -> Void, + failure: @escaping (Error) -> Void) { + createShoppingCart(siteID: siteID, + products: [.domain(domainSuggestion, privacyProtectionEnabled)], + temporary: false, + success: success, + failure: failure) + } +} + +public enum TransactionsServiceProduct { + public typealias ProductId = Int + public typealias PrivacyProtection = Bool + + case domain(DomainSuggestion, PrivacyProtection) + case plan(ProductId) + case other([String: AnyObject]) +} + +public struct CartResponse { + let blogID: Int + let cartKey: Any // cart key can be either Int or String + let products: [Product] + + init?(jsonDictionary: [String: AnyObject]) { + guard + let cartKey = jsonDictionary["cart_key"], + let blogID = jsonDictionary["blog_id"] as? Int, + let products = jsonDictionary["products"] as? [[String: AnyObject]] + else { + return nil + } + + let mappedProducts = products.compactMap { (product) -> Product? in + guard + let productID = product["product_id"] as? Int else { + return nil + } + let meta = product["meta"] as? String + let extra = product["extra"] as? [String: AnyObject] + + return Product(productID: productID, meta: meta, extra: extra) + } + + self.blogID = blogID + self.cartKey = cartKey + self.products = mappedProducts + } + + fileprivate func jsonRepresentation() -> [String: AnyObject] { + return ["blog_id": blogID as AnyObject, + "cart_key": cartKey as AnyObject, + "products": products.map { $0.jsonRepresentation() } as AnyObject] + + } +} + +public struct Product { + let productID: Int + let meta: String? + let extra: [String: AnyObject]? + + fileprivate func jsonRepresentation() -> [String: AnyObject] { + var returnDict: [String: AnyObject] = [:] + + returnDict["product_id"] = productID as AnyObject + + if let meta = meta { + returnDict["meta"] = meta as AnyObject + } + + if let extra = extra { + returnDict["extra"] = extra as AnyObject + } + + return returnDict + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/UsersServiceRemoteXMLRPC.swift b/WordPressKit/Sources/WordPressKit/Services/UsersServiceRemoteXMLRPC.swift new file mode 100644 index 000000000000..ec9b8844e83d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/UsersServiceRemoteXMLRPC.swift @@ -0,0 +1,30 @@ +import Foundation + +public enum UsersServiceRemoteError: Int, Error { + case UnexpectedResponseData +} + +/// UsersServiceRemoteXMLRPC handles Users related XML-RPC calls. +/// https://codex.wordpress.org/XML-RPC_WordPress_API/Users +/// +public class UsersServiceRemoteXMLRPC: ServiceRemoteWordPressXMLRPC { + + /// Fetch the blog user's profile. + /// + public func fetchProfile(_ success: @escaping ((RemoteProfile) -> Void), failure: @escaping ((NSError?) -> Void)) { + let params = defaultXMLRPCArguments() as [AnyObject] + api.callMethod("wp.getProfile", parameters: params, success: { (responseObj, _) in + guard let dict = responseObj as? NSDictionary else { + assertionFailure("A dictionary was expected but the API returned something different.") + failure(UsersServiceRemoteError.UnexpectedResponseData as NSError) + return + } + let profile = RemoteProfile(dictionary: dict) + success(profile) + + }, failure: { (error, _) in + failure(error) + }) + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteCreation.swift b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteCreation.swift new file mode 100644 index 000000000000..c65f80e5f912 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteCreation.swift @@ -0,0 +1,259 @@ +import Foundation + +// MARK: - SiteCreationRequest + +/// This value type is intended to express a site creation request. +/// +public struct SiteCreationRequest: Encodable { + public let segmentIdentifier: Int64? + public let verticalIdentifier: String? + public let title: String + public let tagline: String? + public let siteURLString: String + public let isPublic: Bool + public let languageIdentifier: String + public let shouldValidate: Bool + public let clientIdentifier: String + public let clientSecret: String + public let siteDesign: String? + public let timezoneIdentifier: String? + public let siteCreationFlow: String? + public let findAvailableURL: Bool + + public init(segmentIdentifier: Int64?, + siteDesign: String?, + verticalIdentifier: String?, + title: String, + tagline: String?, + siteURLString: String, + isPublic: Bool, + languageIdentifier: String, + shouldValidate: Bool, + clientIdentifier: String, + clientSecret: String, + timezoneIdentifier: String?, + siteCreationFlow: String?, + findAvailableURL: Bool) { + + self.segmentIdentifier = segmentIdentifier + self.siteDesign = siteDesign + self.verticalIdentifier = verticalIdentifier + self.title = title + self.tagline = tagline + self.siteURLString = siteURLString + self.isPublic = isPublic + self.languageIdentifier = languageIdentifier + self.shouldValidate = shouldValidate + self.clientIdentifier = clientIdentifier + self.clientSecret = clientSecret + self.timezoneIdentifier = timezoneIdentifier + self.siteCreationFlow = siteCreationFlow + self.findAvailableURL = findAvailableURL + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(clientIdentifier, forKey: .clientIdentifier) + try container.encode(clientSecret, forKey: .clientSecret) + try container.encode(languageIdentifier, forKey: .languageIdentifier) + try container.encode(shouldValidate, forKey: .shouldValidate) + try container.encode(siteURLString, forKey: .siteURLString) + try container.encode(title, forKey: .title) + try container.encode(findAvailableURL, forKey: .findAvailableURL) + + let publicValue = isPublic ? 1 : 0 + try container.encode(publicValue, forKey: .isPublic) + + let siteInfo: SiteInformation? + if let tagline = tagline { + siteInfo = SiteInformation(tagline: tagline) + } else { + siteInfo = nil + } + let options = SiteCreationOptions(segmentIdentifier: segmentIdentifier, + verticalIdentifier: verticalIdentifier, + siteInformation: siteInfo, + siteDesign: siteDesign, + timezoneIdentifier: timezoneIdentifier, + siteCreationFlow: siteCreationFlow) + + try container.encode(options, forKey: .options) + } + + private enum CodingKeys: String, CodingKey { + case clientIdentifier = "client_id" + case clientSecret = "client_secret" + case languageIdentifier = "lang_id" + case isPublic = "public" + case shouldValidate = "validate" + case siteURLString = "blog_name" + case title = "blog_title" + case options = "options" + case findAvailableURL = "find_available_url" + } +} + +private struct SiteCreationOptions: Encodable { + let segmentIdentifier: Int64? + let verticalIdentifier: String? + let siteInformation: SiteInformation? + let siteDesign: String? + let timezoneIdentifier: String? + let siteCreationFlow: String? + + enum CodingKeys: String, CodingKey { + case segmentIdentifier = "site_segment" + case verticalIdentifier = "site_vertical" + case siteInformation = "site_information" + case siteDesign = "template" + case timezoneIdentifier = "timezone_string" + case siteCreationFlow = "site_creation_flow" + } +} + +private struct SiteInformation: Encodable { + let tagline: String? + + enum CodingKeys: String, CodingKey { + case tagline = "site_tagline" + } +} + +// MARK: - SiteCreationResponse + +/// This value type is intended to express a site creation response. +/// +public struct SiteCreationResponse: Decodable { + public let createdSite: CreatedSite + public let success: Bool + + enum CodingKeys: String, CodingKey { + case createdSite = "blog_details" + case success + } +} + +/// This value type describes the site that was created. +/// +public struct CreatedSite: Decodable { + public let identifier: String + public let title: String + public let urlString: String + public let xmlrpcString: String + + enum CodingKeys: String, CodingKey { + case identifier = "blogid" + case title = "blogname" + case urlString = "url" + case xmlrpcString = "xmlrpc" + } +} + +// MARK: - WordPressComServiceRemote (Site Creation) + +/// Describes the errors that could arise during the process of site creation. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteCreationError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to site creation requests. +/// +/// - success: the site creation request succeeded with the accompanying result. +/// - failure: the site creation request failed due to the accompanying error. +/// +public enum SiteCreationResult { + case success(SiteCreationResponse) + case failure(SiteCreationError) +} + +public typealias SiteCreationResultHandler = ((SiteCreationResult) -> Void) + +/// Site creation services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Initiates a request to create a new WPCOM site. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the site creation attempt. + /// + func createWPComSite(request: SiteCreationRequest, completion: @escaping SiteCreationResultHandler) { + + let endpoint = "sites/new" + let path = self.path(forEndpoint: endpoint, withVersion: ._1_1) + + let requestParameters: [String: AnyObject] + do { + requestParameters = try encodeRequestParameters(request: request) + } catch { + WPKitLogError("Failed to encode \(SiteCreationRequest.self) : \(error)") + + completion(.failure(SiteCreationError.requestEncodingFailure)) + return + } + + wordPressComRESTAPI.post( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self = self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(.success(response)) + } catch { + WPKitLogError("Failed to decode \(SiteCreationResponse.self) : \(error.localizedDescription)") + completion(.failure(SiteCreationError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteCreationError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + + func encodeRequestParameters(request: SiteCreationRequest) throws -> [String: AnyObject] { + + let encoder = JSONEncoder() + + let jsonData = try encoder.encode(request) + let serializedJSON = try JSONSerialization.jsonObject(with: jsonData, options: []) + + let requestParameters: [String: AnyObject] + if let jsonDictionary = serializedJSON as? [String: AnyObject] { + requestParameters = jsonDictionary + } else { + requestParameters = [:] + } + + return requestParameters + } + + func decodeResponse(responseObject: Any) throws -> SiteCreationResponse { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode(SiteCreationResponse.self, from: data) + + return response + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteSegments.swift b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteSegments.swift new file mode 100644 index 000000000000..179d0a2157dc --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteSegments.swift @@ -0,0 +1,138 @@ +/// Models a type of site. +public struct SiteSegment { + public let identifier: Int64 // we use a numeric ID for segments; see p9wMUP-bH-612-p2 for discussion + public let title: String + public let subtitle: String + public let icon: URL? + public let iconColor: String? + public let mobile: Bool + + public init(identifier: Int64, title: String, subtitle: String, icon: URL?, iconColor: String?, mobile: Bool) { + self.identifier = identifier + self.title = title + self.subtitle = subtitle + self.icon = icon + self.iconColor = iconColor + self.mobile = mobile + } +} + +extension SiteSegment: Equatable { + public static func ==(lhs: SiteSegment, rhs: SiteSegment) -> Bool { + return lhs.identifier == rhs.identifier + } +} + +extension SiteSegment: Decodable { + enum CodingKeys: String, CodingKey { + case segmentId = "id" + case segmentTypeTitle = "segment_type_title" + case segmentTypeSubtitle = "segment_type_subtitle" + case iconURL = "icon_URL" + case iconColor = "icon_color" + case mobile = "mobile" + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + identifier = try values.decode(Int64.self, forKey: .segmentId) + title = try values.decode(String.self, forKey: .segmentTypeTitle) + subtitle = try values.decode(String.self, forKey: .segmentTypeSubtitle) + if let iconString = try values.decodeIfPresent(String.self, forKey: .iconURL) { + icon = URL(string: iconString) + } else { + icon = nil + } + + if let iconColorString = try values.decodeIfPresent(String.self, forKey: .iconColor) { + var cleanIconColorString = iconColorString + if iconColorString.hasPrefix("#") { + cleanIconColorString = String(iconColorString.dropFirst(1)) + } + + iconColor = cleanIconColorString + } else { + iconColor = nil + } + + mobile = try values.decode(Bool.self, forKey: .mobile) + + } +} + +// MARK: - WordPressComServiceRemote (Site Segments) + +/// Describes the errors that could arise when searching for site verticals. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteSegmentsError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to requests for site segments. +/// +/// - success: the site segments request succeeded with the accompanying result. +/// - failure: the site segments request failed due to the accompanying error. +/// +public enum SiteSegmentsResult { + case success([SiteSegment]) + case failure(SiteSegmentsError) +} + +public typealias SiteSegmentsServiceCompletion = (SiteSegmentsResult) -> Void + +/// Site segments service, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + func retrieveSegments(completion: @escaping SiteSegmentsServiceCompletion) { + let endpoint = "segments" + let remotePath = path(forEndpoint: endpoint, withVersion: ._2_0) + + wordPressComRESTAPI.get( + remotePath, + parameters: nil, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self = self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + let validContent = self.validSegments(response) + completion(.success(validContent)) + } catch { + WPKitLogError("Failed to decode \([SiteVertical].self) : \(error.localizedDescription)") + completion(.failure(SiteSegmentsError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteSegmentsError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + private func decodeResponse(responseObject: Any) throws -> [SiteSegment] { + let decoder = JSONDecoder() + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode([SiteSegment].self, from: data) + + return response + } + + private func validSegments(_ allSegments: [SiteSegment]) -> [SiteSegment] { + return allSegments.filter { + return $0.mobile == true + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticals.swift b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticals.swift new file mode 100644 index 000000000000..81e54e85775a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticals.swift @@ -0,0 +1,150 @@ +import Foundation + +// MARK: - SiteVerticalsRequest + +/// Allows the construction of a request for site verticals. +/// +/// NB: The default limit (5) applies to the number of results returned by the service. If a search with limit n evinces no exact match, (n - 1) server-unique results are returned. +/// +public struct SiteVerticalsRequest: Encodable { + public let search: String + public let limit: Int + + public init(search: String, limit: Int = 5) { + self.search = search + self.limit = limit + } +} + +// MARK: - SiteVertical(s) : Response + +/// Models a Site Vertical +/// +public struct SiteVertical: Decodable, Equatable { + public let identifier: String // vertical IDs mix parent/child taxonomy (String) + public let title: String + public let isNew: Bool + + public init(identifier: String, + title: String, + isNew: Bool) { + + self.identifier = identifier + self.title = title + self.isNew = isNew + } + + private enum CodingKeys: String, CodingKey { + case identifier = "vertical_id" + case title = "vertical_name" + case isNew = "is_user_input_vertical" + } +} + +// MARK: - WordPressComServiceRemote (Site Verticals) + +/// Describes the errors that could arise when searching for site verticals. +/// +/// - requestEncodingFailure: unable to encode the request parameters. +/// - responseDecodingFailure: unable to decode the server response. +/// - serviceFailure: the service returned an unexpected error. +/// +public enum SiteVerticalsError: Error { + case requestEncodingFailure + case responseDecodingFailure + case serviceFailure +} + +/// Advises the caller of results related to requests for site verticals. +/// +/// - success: the site verticals request succeeded with the accompanying result. +/// - failure: the site verticals request failed due to the accompanying error. +/// +public enum SiteVerticalsResult { + case success([SiteVertical]) + case failure(SiteVerticalsError) +} + +public typealias SiteVerticalsServiceCompletion = ((SiteVerticalsResult) -> Void) + +/// Site verticals services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Retrieves Verticals matching the specified criteria. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the request for site verticals. + /// + func retrieveVerticals(request: SiteVerticalsRequest, completion: @escaping SiteVerticalsServiceCompletion) { + + let endpoint = "verticals" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let requestParameters: [String: AnyObject] + do { + requestParameters = try encodeRequestParameters(request: request) + } catch { + WPKitLogError("Failed to encode \(SiteCreationRequest.self) : \(error)") + + completion(.failure(SiteVerticalsError.requestEncodingFailure)) + return + } + + wordPressComRESTAPI.get( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self = self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(.success(response)) + } catch { + WPKitLogError("Failed to decode \([SiteVertical].self) : \(error.localizedDescription)") + completion(.failure(SiteVerticalsError.responseDecodingFailure)) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(.failure(SiteVerticalsError.serviceFailure)) + }) + } +} + +// MARK: - Serialization support + +private extension WordPressComServiceRemote { + + func encodeRequestParameters(request: SiteVerticalsRequest) throws -> [String: AnyObject] { + + let encoder = JSONEncoder() + + let jsonData = try encoder.encode(request) + let serializedJSON = try JSONSerialization.jsonObject(with: jsonData, options: []) + + let requestParameters: [String: AnyObject] + if let jsonDictionary = serializedJSON as? [String: AnyObject] { + requestParameters = jsonDictionary + } else { + requestParameters = [:] + } + + return requestParameters + } + + func decodeResponse(responseObject: Any) throws -> [SiteVertical] { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode([SiteVertical].self, from: data) + + return response + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticalsPrompt.swift b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticalsPrompt.swift new file mode 100644 index 000000000000..bfee3bff8cb7 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote+SiteVerticalsPrompt.swift @@ -0,0 +1,86 @@ +import Foundation + +// MARK: - Site Verticals Prompt : Request + +public typealias SiteVerticalsPromptRequest = Int64 + +// MARK: - Site Verticals Prompt : Response + +public struct SiteVerticalsPrompt: Decodable { + public let title: String + public let subtitle: String + public let hint: String + + public init(title: String, subtitle: String, hint: String) { + self.title = title + self.subtitle = subtitle + self.hint = hint + } + + private enum CodingKeys: String, CodingKey { + case title = "site_topic_header" + case subtitle = "site_topic_subheader" + case hint = "site_topic_placeholder" + } +} + +public typealias SiteVerticalsPromptServiceCompletion = ((SiteVerticalsPrompt?) -> Void) + +/// Site verticals services, exclusive to WordPress.com. +/// +public extension WordPressComServiceRemote { + + /// Retrieves the prompt information presented to users when searching Verticals. + /// + /// - Parameters: + /// - request: the value object with which to compose the request. + /// - completion: a closure including the result of the request for site verticals. + /// + func retrieveVerticalsPrompt(request: SiteVerticalsPromptRequest, completion: @escaping SiteVerticalsPromptServiceCompletion) { + + let endpoint = "verticals/prompt" + let path = self.path(forEndpoint: endpoint, withVersion: ._2_0) + + let requestParameters: [String: AnyObject] = [ + "segment_id": request as AnyObject + ] + + wordPressComRESTAPI.get( + path, + parameters: requestParameters, + success: { [weak self] responseObject, httpResponse in + WPKitLogInfo("\(responseObject) | \(String(describing: httpResponse))") + + guard let self = self else { + return + } + + do { + let response = try self.decodeResponse(responseObject: responseObject) + completion(response) + } catch { + WPKitLogError("Failed to decode SiteVerticalsPrompt : \(error.localizedDescription)") + completion(nil) + } + }, + failure: { error, httpResponse in + WPKitLogError("\(error) | \(String(describing: httpResponse))") + completion(nil) + }) + } +} + +// MARK: - Serialization support +// +private extension WordPressComServiceRemote { + + func decodeResponse(responseObject: Any) throws -> SiteVerticalsPrompt { + + let decoder = JSONDecoder() + + let data = try JSONSerialization.data(withJSONObject: responseObject, options: []) + let response = try decoder.decode(SiteVerticalsPrompt.self, from: data) + + return response + } +} diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.h b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.h new file mode 100644 index 000000000000..d4b51c87d9ba --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.h @@ -0,0 +1,105 @@ +#import +#import + +typedef NS_ENUM(NSUInteger, WordPressComServiceBlogVisibility) { + WordPressComServiceBlogVisibilityPublic = 0, + WordPressComServiceBlogVisibilityPrivate = 1, + WordPressComServiceBlogVisibilityHidden = 2, +}; + +typedef void(^WordPressComServiceSuccessBlock)(NSDictionary *responseDictionary); +typedef void(^WordPressComServiceFailureBlock)(NSError *error); + +/** + * @class WordPressComServiceRemote + * @brief Encapsulates exclusive WordPress.com services. + */ +@interface WordPressComServiceRemote : ServiceRemoteWordPressComREST + +/** + * @brief Creates a WordPress.com account with the specified parameters. + * + * @param email The email to use for the new account. Cannot be nil. + * @param username The username of the new account. Cannot be nil. + * @param password The password of the new account. Cannot be nil. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + Create a new account using Google + + @param token token provided by Google + @param clientID wpcom client id + @param clientSecret wpcom secret + @param success success block + @param failure failure block + */ +- (void)createWPComAccountWithGoogle:(NSString *)token + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Create a new WordPress.com account from Apple ID credentials. + * + * @param token Token provided by Apple. + * @param email Apple email to use for new account. + * @param fullName The user's full name for the new account. Formed from the fullname + * property in the Apple ID credential. + * @param clientID wpcom client ID. + * @param clientSecret wpcom secret. + * @param success success block. + * @param failure failure block. + */ +- (void)createWPComAccountWithApple:(NSString *)token + andEmail:(NSString *)email + andFullName:(NSString *)fullName + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Validates a WordPress.com blog with the specified parameters. + * + * @param blogUrl The url of the blog to validate. Cannot be nil. + * @param blogTitle The title of the blog. Can be nil. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)validateWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +/** + * @brief Creates a WordPress.com blog with the specified parameters. + * + * @param blogUrl The url of the blog to validate. Cannot be nil. + * @param blogTitle The title of the blog. Can be nil. + * @param visibility The visibility of the new blog. + * @param success The block to execute on success. Can be nil. + * @param failure The block to execute on failure. Can be nil. + */ +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.m b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.m new file mode 100644 index 000000000000..28555c7ea403 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Services/WordPressComServiceRemote.m @@ -0,0 +1,341 @@ +#import "WordPressComServiceRemote.h" +#import "WPKit-Swift.h" +@import NSObject_SafeExpectations; +@import WordPressShared; + +@implementation WordPressComServiceRemote + +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([email isKindOfClass:[NSString class]]); + NSParameterAssert([username isKindOfClass:[NSString class]]); + NSParameterAssert([password isKindOfClass:[NSString class]]); + + [self createWPComAccountWithEmail:email + andUsername:username + andPassword:password + andClientID:clientID + andClientSecret:clientSecret + validate:NO + success:success + failure:failure]; +} + +- (void)createWPComAccountWithEmail:(NSString *)email + andUsername:(NSString *)username + andPassword:(NSString *)password + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + validate:(BOOL)validate + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([email isKindOfClass:[NSString class]]); + NSParameterAssert([username isKindOfClass:[NSString class]]); + NSParameterAssert([password isKindOfClass:[NSString class]]); + + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + success(responseObject); + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + NSDictionary *params = @{ + @"email": email, + @"username": username, + @"password": password, + @"validate": @(validate), + @"client_id": clientID, + @"client_secret": clientSecret + }; + + NSString *requestUrl = [self pathForEndpoint:@"users/new" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +- (void)createWPComAccountWithGoogle:(NSString *)token + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSDictionary *params = @{ + @"client_id": clientID, + @"client_secret": clientSecret, + @"id_token": token, + @"service": @"google", + @"signup_flow_name": @"social", + }; + + [self createSocialWPComAccountWithParams:params success:success failure:failure]; +} + +- (void)createWPComAccountWithApple:(NSString *)token + andEmail:(NSString *)email + andFullName:(NSString *)fullName + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSDictionary *params = @{ + @"client_id": clientID, + @"client_secret": clientSecret, + @"id_token": token, + @"service": @"apple", + @"signup_flow_name": @"social", + @"user_email": email, + @"user_name": fullName, + }; + + [self createSocialWPComAccountWithParams:params success:success failure:failure]; +} + +- (void)createSocialWPComAccountWithParams:(NSDictionary *)params + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + success(responseObject); + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + NSString *requestUrl = [self pathForEndpoint:@"users/social/new" withVersion:WordPressComRESTAPIVersion_1_0]; + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +- (void)validateWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + [self createWPComBlogWithUrl:blogUrl + andBlogTitle:blogTitle + andLanguageId:languageId + andBlogVisibility:WordPressComServiceBlogVisibilityPublic + andClientID:clientID + andClientSecret:clientSecret + validate:YES + success:success + failure:failure]; +} + +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + [self createWPComBlogWithUrl:blogUrl + andBlogTitle:blogTitle + andLanguageId:languageId + andBlogVisibility:visibility + andClientID:clientID + andClientSecret:clientSecret + validate:NO + success:success + failure:failure]; +} + +- (void)createWPComBlogWithUrl:(NSString *)blogUrl + andBlogTitle:(NSString *)blogTitle + andLanguageId:(NSString *)languageId + andBlogVisibility:(WordPressComServiceBlogVisibility)visibility + andClientID:(NSString *)clientID + andClientSecret:(NSString *)clientSecret + validate:(BOOL)validate + success:(WordPressComServiceSuccessBlock)success + failure:(WordPressComServiceFailureBlock)failure +{ + NSParameterAssert([blogUrl isKindOfClass:[NSString class]]); + NSParameterAssert([languageId isKindOfClass:[NSString class]]); + + void (^successBlock)(id, NSHTTPURLResponse *) = ^(id responseObject, NSHTTPURLResponse *httpResponse) { + NSDictionary *response = responseObject; + if ([response count] == 0) { + // There was an error creating the blog as a successful call yields a dictionary back. + NSString *localizedErrorMessage = NSLocalizedString(@"Unknown error", nil); + NSMutableDictionary *userInfo = [[NSMutableDictionary alloc] init]; + userInfo[WordPressComRestApi.ErrorKeyErrorMessage] = localizedErrorMessage; + userInfo[NSLocalizedDescriptionKey] = localizedErrorMessage; + NSError *errorWithLocalizedMessage = [[NSError alloc] initWithDomain:WordPressComRestApiErrorDomain + code:WordPressComRestApiErrorCodeUnknown + userInfo:userInfo]; + failure(errorWithLocalizedMessage); + } else { + success(responseObject); + } + }; + + void (^failureBlock)(NSError *, NSHTTPURLResponse *) = ^(NSError *error, NSHTTPURLResponse *httpResponse){ + NSError *errorWithLocalizedMessage = [self errorWithLocalizedMessage:error]; + failure(errorWithLocalizedMessage); + }; + + if (blogTitle == nil) { + blogTitle = @""; + } + + int blogVisibility = 1; + if (visibility == WordPressComServiceBlogVisibilityPublic) { + blogVisibility = 1; + } else if (visibility == WordPressComServiceBlogVisibilityPrivate) { + blogVisibility = -1; + } else { + // Hidden + blogVisibility = 0; + } + + NSDictionary *params = @{ + @"blog_name": blogUrl, + @"blog_title": blogTitle, + @"lang_id": languageId, + @"public": @(blogVisibility), + @"validate": @(validate), + @"client_id": clientID, + @"client_secret": clientSecret + }; + + + NSString *requestUrl = [self pathForEndpoint:@"sites/new" + withVersion:WordPressComRESTAPIVersion_1_1]; + + [self.wordPressComRESTAPI post:requestUrl parameters:params success:successBlock failure:failureBlock]; +} + +#pragma mark - Error localization + +- (NSError *)errorWithLocalizedMessage:(NSError *)error { + NSError *errorWithLocalizedMessage = error; + if ([error.domain isEqual:WordPressComRestApiErrorDomain] && + [error.userInfo objectForKey:WordPressComRestApi.ErrorKeyErrorCode] != nil) { + + NSString *localizedErrorMessage = [self errorMessageForError:error]; + NSString *errorCode = [error.userInfo objectForKey:WordPressComRestApi.ErrorKeyErrorCode]; + NSMutableDictionary *userInfo = [[NSMutableDictionary alloc] initWithDictionary:error.userInfo]; + userInfo[WordPressComRestApi.ErrorKeyErrorCode] = errorCode; + userInfo[WordPressComRestApi.ErrorKeyErrorMessage] = localizedErrorMessage; + userInfo[NSLocalizedDescriptionKey] = localizedErrorMessage; + errorWithLocalizedMessage = [[NSError alloc] initWithDomain:error.domain code:error.code userInfo:userInfo]; + } + return errorWithLocalizedMessage; +} + +- (NSString *)errorMessageForError:(NSError *)error +{ + NSString *errorCode = [error.userInfo stringForKey:WordPressComRestApi.ErrorKeyErrorCode]; + NSString *errorMessage = [[error.userInfo stringForKey:NSLocalizedDescriptionKey] stringByStrippingHTML]; + + if ([errorCode isEqualToString:@"username_only_lowercase_letters_and_numbers"]) { + return NSLocalizedString(@"Sorry, usernames can only contain lowercase letters (a-z) and numbers.", nil); + } else if ([errorCode isEqualToString:@"username_required"]) { + return NSLocalizedString(@"Please enter a username.", nil); + } else if ([errorCode isEqualToString:@"username_not_allowed"]) { + return NSLocalizedString(@"That username is not allowed.", nil); + } else if ([errorCode isEqualToString:@"email_cant_be_used_to_signup"]) { + return NSLocalizedString(@"You cannot use that email address to signup. We are having problems with them blocking some of our email. Please use another email provider.", nil); + } else if ([errorCode isEqualToString:@"username_must_be_at_least_four_characters"]) { + return NSLocalizedString(@"Username must be at least 4 characters.", nil); + } else if ([errorCode isEqualToString:@"username_contains_invalid_characters"]) { + return NSLocalizedString(@"Sorry, usernames may not contain the character “_”!", nil); + } else if ([errorCode isEqualToString:@"username_must_include_letters"]) { + return NSLocalizedString(@"Sorry, usernames must have letters (a-z) too!", nil); + } else if ([errorCode isEqualToString:@"email_not_allowed"]) { + return NSLocalizedString(@"Sorry, that email address is not allowed!", nil); + } else if ([errorCode isEqualToString:@"username_exists"]) { + return NSLocalizedString(@"Sorry, that username already exists!", nil); + } else if ([errorCode isEqualToString:@"email_exists"]) { + return NSLocalizedString(@"Sorry, that email address is already being used!", nil); + } else if ([errorCode isEqualToString:@"username_reserved_but_may_be_available"]) { + return NSLocalizedString(@"That username is currently reserved but may be available in a couple of days.", nil); + } else if ([errorCode isEqualToString:@"username_unavailable"]) { + return NSLocalizedString(@"Sorry, that username is unavailable.", nil); + } else if ([errorCode isEqualToString:@"email_reserved"]) { + return NSLocalizedString(@"That email address has already been used. Please check your inbox for an activation email. If you don't activate you can try again in a few days.", nil); + } else if ([errorCode isEqualToString:@"blog_name_required"]) { + return NSLocalizedString(@"Please enter a site address.", nil); + } else if ([errorCode isEqualToString:@"blog_name_not_allowed"]) { + return NSLocalizedString(@"That site address is not allowed.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_be_at_least_four_characters"]) { + return NSLocalizedString(@"Site address must be at least 4 characters.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_be_less_than_sixty_four_characters"]) { + return NSLocalizedString(@"The site address must be shorter than 64 characters.", nil); + } else if ([errorCode isEqualToString:@"blog_name_contains_invalid_characters"]) { + return NSLocalizedString(@"Sorry, site addresses may not contain the character “_”!", nil); + } else if ([errorCode isEqualToString:@"blog_name_cant_be_used"]) { + return NSLocalizedString(@"Sorry, you may not use that site address.", nil); + } else if ([errorCode isEqualToString:@"blog_name_only_lowercase_letters_and_numbers"]) { + return NSLocalizedString(@"Sorry, site addresses can only contain lowercase letters (a-z) and numbers.", nil); + } else if ([errorCode isEqualToString:@"blog_name_must_include_letters"]) { + return NSLocalizedString(@"Sorry, site addresses must have letters too!", nil); + } else if ([errorCode isEqualToString:@"blog_name_exists"]) { + return NSLocalizedString(@"Sorry, that site already exists!", nil); + } else if ([errorCode isEqualToString:@"blog_name_reserved"]) { + return NSLocalizedString(@"Sorry, that site is reserved!", nil); + } else if ([errorCode isEqualToString:@"blog_name_reserved_but_may_be_available"]) { + return NSLocalizedString(@"That site is currently reserved but may be available in a couple days.", nil); + } else if ([errorCode isEqualToString:@"password_invalid"]) { + return NSLocalizedString(@"Sorry, that password does not meet our security guidelines. Please choose a password with a minimum length of six characters, mixing uppercase letters, lowercase letters, numbers and symbols.", @"This error message occurs when a user tries to create an account with a weak password."); + } else if ([errorCode isEqualToString:@"blog_title_invalid"]) { + return NSLocalizedString(@"Invalid Site Title", @""); + } else if ([errorCode isEqualToString:@"username_illegal_wpcom"]) { + // Try to extract the illegal phrase + NSError *error; + NSRegularExpression *regEx = [NSRegularExpression regularExpressionWithPattern:@"\"([^\"].*)\"" options:NSRegularExpressionCaseInsensitive error:&error]; + NSArray *matches = [regEx matchesInString:errorMessage options:0 range:NSMakeRange(0, [errorMessage length])]; + NSString *invalidPhrase = @""; + for (NSTextCheckingResult *result in matches) { + if ([result numberOfRanges] < 2) + continue; + NSRange invalidTextRange = [result rangeAtIndex:1]; + invalidPhrase = [NSString stringWithFormat:@" (\"%@\")", [errorMessage substringWithRange:invalidTextRange]]; + } + + return [NSString stringWithFormat:NSLocalizedString(@"Sorry, but your username contains an invalid phrase%@.", @"This error message occurs when a user tries to create a username that contains an invalid phrase for WordPress.com. The %@ may include the phrase in question if it was sent down by the API"), invalidPhrase]; + } + + // We have a few ambiguous errors that come back from the api, they sometimes have error messages included so + // attempt to return that if possible. If not fall back to a generic error. + NSDictionary *ambiguousErrors = @{ + @"email_invalid": NSLocalizedString(@"Please enter a valid email address.", nil), + @"blog_name_invalid" : NSLocalizedString(@"Invalid Site Address", @""), + @"username_invalid" : NSLocalizedString(@"Invalid username", @"") + }; + if ([ambiguousErrors.allKeys containsObject:errorCode]) { + if (errorMessage != nil) { + return errorMessage; + } + + return [ambiguousErrors objectForKey:errorCode]; + } + + // Return an error message if there's one included rather than the unhelpful "Unknown Error" + if (errorMessage != nil) { + return errorMessage; + } + + return NSLocalizedString(@"Unknown error", nil); +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Utility/ChecksumUtil.swift b/WordPressKit/Sources/WordPressKit/Utility/ChecksumUtil.swift new file mode 100644 index 000000000000..e9c3035b1e9e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/ChecksumUtil.swift @@ -0,0 +1,18 @@ +import Foundation + +public class ChecksumUtil { + + /// Generates a checksum based on the encoded keys. + static func checksum(from codable: T) -> String where T: Encodable { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let result: String + do { + let data = try encoder.encode(codable) + result = String(data: data, encoding: .utf8) ?? "" + } catch { + result = "" + } + return result.md5() + } +} diff --git a/WordPressKit/Sources/WordPressKit/Utility/HTTPProtocolHelpers.swift b/WordPressKit/Sources/WordPressKit/Utility/HTTPProtocolHelpers.swift new file mode 100644 index 000000000000..b446e31d8cdb --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/HTTPProtocolHelpers.swift @@ -0,0 +1,61 @@ +import Foundation + +extension HTTPURLResponse { + + /// Return parameter value in a header field. + /// + /// For example, you can use this method to get "charset" value from a 'Content-Type' header like + /// `Content-Type: applications/json; charset=utf-8`. + func value(ofParameter parameterName: String, inHeaderField headerName: String, stripQuotes: Bool = true) -> String? { + guard let headerValue = value(forHTTPHeaderField: headerName) else { + return nil + } + + return Self.value(ofParameter: parameterName, inHeaderValue: headerValue, stripQuotes: stripQuotes) + } + + func value(forHTTPHeaderField field: String, withoutParameters: Bool) -> String? { + guard withoutParameters else { + return value(forHTTPHeaderField: field) + } + + guard let headerValue = value(forHTTPHeaderField: field) else { + return nil + } + + guard let firstSemicolon = headerValue.firstIndex(of: ";") else { + return headerValue + } + + return String(headerValue[headerValue.startIndex.. String? { + // Find location of '=' string in the header. + guard let location = headerValue.range(of: parameterName + "=", options: .caseInsensitive) else { + return nil + } + + let parameterValueStart = location.upperBound + let parameterValueEnd: String.Index + + // ';' marks the end of the parameter value. + if let found = headerValue.range(of: ";", range: parameterValueStart.. + +@interface NSMutableDictionary (Helpers) +- (void)setValueIfNotNil:(id)value forKey:(NSString *)key; +@end diff --git a/WordPressKit/Sources/WordPressKit/Utility/NSMutableDictionary+Helpers.m b/WordPressKit/Sources/WordPressKit/Utility/NSMutableDictionary+Helpers.m new file mode 100644 index 000000000000..214b6829c90a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/NSMutableDictionary+Helpers.m @@ -0,0 +1,10 @@ +#import "NSMutableDictionary+Helpers.h" + +@implementation NSMutableDictionary (Helpers) +- (void)setValueIfNotNil:(id)value forKey:(NSString *)key +{ + if (value != nil) { + self[key] = value; + } +} +@end diff --git a/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.h b/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.h new file mode 100644 index 000000000000..e1c599fbf288 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.h @@ -0,0 +1,7 @@ +#import + +@interface NSString (MD5) + +- (NSString *)md5; + +@end diff --git a/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.m b/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.m new file mode 100644 index 000000000000..23d5519d5c24 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/NSString+MD5.m @@ -0,0 +1,24 @@ +#import "NSString+MD5.h" +#import + + +@implementation NSString (MD5) + +- (NSString *)md5 +{ + const char *cStr = [self UTF8String]; + unsigned char result[CC_MD5_DIGEST_LENGTH]; + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" // because Apple considers MD5 insecure + CC_MD5(cStr, (CC_LONG)strlen(cStr), result); +#pragma clang diagnostic pop + + return [NSString stringWithFormat: + @"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + result[0], result[1], result[2], result[3], result[4], result[5], result[6], result[7], + result[8], result[9], result[10], result[11], result[12], result[13], result[14], result[15] + ]; +} + +@end diff --git a/WordPressKit/Sources/WordPressKit/Utility/ObjectValidation.swift b/WordPressKit/Sources/WordPressKit/Utility/ObjectValidation.swift new file mode 100644 index 000000000000..8ecffe7a1793 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/ObjectValidation.swift @@ -0,0 +1,21 @@ +import Foundation + +@objc public extension NSObject { + + /// Validate if a class is a valid NSObject and if it's not nil + /// + /// - Returns: Bool value + func wp_isValidObject() -> Bool { + return !(self is NSNull) + } +} + +@objc public extension NSString { + + /// Validate if a class is a valid NSString and if it's not nil + /// + /// - Returns: Bool value + func wp_isValidString() -> Bool { + return wp_isValidObject() && self != "" + } +} diff --git a/WordPressKit/Sources/WordPressKit/Utility/ZendeskMetadata.swift b/WordPressKit/Sources/WordPressKit/Utility/ZendeskMetadata.swift new file mode 100644 index 000000000000..4090f0d4f508 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Utility/ZendeskMetadata.swift @@ -0,0 +1,29 @@ +public struct ZendeskSiteContainer: Decodable { + public let sites: [ZendeskSite] +} + +public struct ZendeskSite: Decodable { + public let ID: Int + public let zendeskMetadata: ZendeskMetadata + + private enum CodingKeys: String, CodingKey { + case ID = "ID" + case zendeskMetadata = "zendesk_site_meta" + } +} + +public struct ZendeskMetadata: Decodable { + public let plan: String + public let jetpackAddons: [String] + + private enum CodingKeys: String, CodingKey { + case plan = "plan" + case jetpackAddons = "addon" + } +} + +/// Errors generated by the metadata decoding process +public enum PlanServiceRemoteError: Error { + // thrown when no metadata were found + case noMetadata +} diff --git a/WordPressKit/Sources/WordPressKit/WordPressKit.h b/WordPressKit/Sources/WordPressKit/WordPressKit.h new file mode 100644 index 000000000000..482fdb30e7c0 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/WordPressKit.h @@ -0,0 +1,57 @@ +#import + +//! Project version number for WordPressKit. +FOUNDATION_EXPORT double WordPressKitVersionNumber; + +//! Project version string for WordPressKit. +FOUNDATION_EXPORT const unsigned char WordPressKitVersionString[]; + +#import +#import +#import +#import +#import + +#import +#import +#import + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import +#import + +#import + +#import diff --git a/WordPressKit/Tests/CoreAPITests/AppTransportSecuritySettingsTests.swift b/WordPressKit/Tests/CoreAPITests/AppTransportSecuritySettingsTests.swift new file mode 100644 index 000000000000..0fd612fccfc0 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/AppTransportSecuritySettingsTests.swift @@ -0,0 +1,103 @@ +import XCTest +#if SWIFT_PACKAGE +@testable import CoreAPI +#else +@testable import WordPressKit +#endif + +final class AppTransportSecuritySettingsTests: XCTestCase { + + private var exampleURL = URL(string: "https://example.com")! + + func testReturnsTrueIfAllowsLocalNetworkingIsTrue() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: [ + "NSAllowsLocalNetworking": true, + // This will be ignored + "NSAllowsArbitraryLoads": true + ]) + let appTransportSecurity = AppTransportSecuritySettings(provider) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: exampleURL) + + // Then + XCTAssertTrue(secureAccessOnly) + } + + func testReturnsFalseIfAllowsArbitraryLoadsIsTrue() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: [ + "NSAllowsArbitraryLoads": true + ]) + let appTransportSecurity = AppTransportSecuritySettings(provider) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: exampleURL) + + // Then + XCTAssertFalse(secureAccessOnly) + } + + func testReturnsTrueByDefault() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: nil) + let appTransportSecurity = AppTransportSecuritySettings(provider) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: exampleURL) + + // Then + XCTAssertTrue(secureAccessOnly) + } + + func testReturnsTrueIfNothingIsDefined() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: [String: Any]()) + let appTransportSecurity = AppTransportSecuritySettings(provider) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: exampleURL) + + // Then + XCTAssertTrue(secureAccessOnly) + } + + func testReturnsFalseIfAllowsInsecureHTTPLoadsIsTrue() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: [ + "NSExceptionDomains": [ + "shiki.me": [ + "NSExceptionAllowsInsecureHTTPLoads": true + ] + ] + ]) + let appTransportSecurity = AppTransportSecuritySettings(provider) + let url = try XCTUnwrap(URL(string: "http://shiki.me")) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: url) + + // Then + XCTAssertFalse(secureAccessOnly) + } + + func testReturnsTrueIfAllowsInsecureHTTPLoadsIsNotProvided() throws { + // Given + let provider = FakeInfoDictionaryObjectProvider(appTransportSecurity: [ + "NSExceptionDomains": [ + "shiki.me": [String: Any]() + ], + // This value will be ignored because there is an exception for shiki.me + "NSAllowsArbitraryLoads": true + ]) + let appTransportSecurity = AppTransportSecuritySettings(provider) + let url = try XCTUnwrap(URL(string: "http://shiki.me")) + + // When + let secureAccessOnly = appTransportSecurity.secureAccessOnly(for: url) + + // Then + XCTAssertTrue(secureAccessOnly) + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Bundle+SPMSupport.swift b/WordPressKit/Tests/CoreAPITests/Bundle+SPMSupport.swift new file mode 100644 index 000000000000..0c3d02c28c0b --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Bundle+SPMSupport.swift @@ -0,0 +1,25 @@ +import Foundation + +extension Bundle { + /// Returns the `Bundle` for the target. + /// + /// If installed via CocoaPods, this will be `.bundle`, otherwise it will be the framework bundle. + @objc public class var coreAPITestsBundle: Bundle { +#if SWIFT_PACKAGE + return Bundle.module +#else + let defaultBundle = Bundle(for: BundleFinder.self) + + guard let bundleURL = defaultBundle.resourceURL, + let resourceBundle = Bundle(url: bundleURL.appendingPathComponent("CoreAPITests.bundle")) else { + return defaultBundle + } + + return resourceBundle +#endif + } +} + +#if !SWIFT_PACKAGE +private class BundleFinder: NSObject {} +#endif diff --git a/WordPressKit/Tests/CoreAPITests/FakeInfoDictionaryObjectProvider.swift b/WordPressKit/Tests/CoreAPITests/FakeInfoDictionaryObjectProvider.swift new file mode 100644 index 000000000000..74569e5bfab6 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/FakeInfoDictionaryObjectProvider.swift @@ -0,0 +1,21 @@ +#if SWIFT_PACKAGE +@testable import CoreAPI +#else +@testable import WordPressKit +#endif + +class FakeInfoDictionaryObjectProvider: InfoDictionaryObjectProvider { + private let appTransportSecurity: [String: Any]? + + init(appTransportSecurity: [String: Any]?) { + self.appTransportSecurity = appTransportSecurity + } + + func object(forInfoDictionaryKey key: String) -> Any? { + if key == "NSAppTransportSecurity" { + return appTransportSecurity + } + + return nil + } +} diff --git a/WordPressKit/Tests/CoreAPITests/HTTPRequestBuilderTests.swift b/WordPressKit/Tests/CoreAPITests/HTTPRequestBuilderTests.swift new file mode 100644 index 000000000000..4b455c76cb3e --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/HTTPRequestBuilderTests.swift @@ -0,0 +1,431 @@ +#if SWIFT_PACKAGE +@testable import CoreAPI +#else +@testable import WordPressKit +#endif +import wpxmlrpc +import XCTest + +class HTTPRequestBuilderTests: XCTestCase { + + static let nestedParameters: [String: Any] = + [ + "number": 1, + "nsnumber-true": NSNumber(value: true), + "true": true, + "false": false, + "string": "true", + "dict": ["foo": true, "bar": "string"], + "nested-dict": [ + "outer1": [ + "inner1": "value1", + "inner2": "value2" + ], + "outer2": [ + "inner1": "value1", + "inner2": "value2" + ] + ], + "array": ["true", 1, false] + ] + static let nestedParametersEncoded = [ + "number=1", + "nsnumber-true=1", + "true=1", + "false=0", + "string=true", + "dict[foo]=1", + "dict[bar]=string", + "nested-dict[outer1][inner1]=value1", + "nested-dict[outer1][inner2]=value2", + "nested-dict[outer2][inner1]=value1", + "nested-dict[outer2][inner2]=value2", + "array[]=true", + "array[]=1", + "array[]=0", + ] + + func testURL() throws { + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).build().url?.absoluteString, "https://wordpress.org") + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.com")!).build().url?.absoluteString, "https://wordpress.com") + } + + func testHTTPMethods() throws { + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).build().httpMethod, "GET") + XCTAssertFalse(HTTPRequestBuilder.Method.get.allowsHTTPBody) + + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).method(.delete).build().httpMethod, "DELETE") + XCTAssertFalse(HTTPRequestBuilder.Method.delete.allowsHTTPBody) + + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).method(.post).build().httpMethod, "POST") + XCTAssertTrue(HTTPRequestBuilder.Method.post.allowsHTTPBody) + + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).method(.patch).build().httpMethod, "PATCH") + XCTAssertTrue(HTTPRequestBuilder.Method.patch.allowsHTTPBody) + + try XCTAssertEqual(HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!).method(.put).build().httpMethod, "PUT") + XCTAssertTrue(HTTPRequestBuilder.Method.put.allowsHTTPBody) + } + + func testHeader() throws { + let request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .header(name: "X-Header-1", value: "Foo") + .header(name: "X-Header-2", value: "Bar") + .build() + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Header-1"), "Foo") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Header-2"), "Bar") + } + + func testPath() throws { + var request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .append(percentEncodedPath: "hello/world") + .build() + XCTAssertEqual(request.url?.absoluteString, "https://wordpress.org/hello/world") + + request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .append(percentEncodedPath: "/hello/world") + .build() + XCTAssertEqual(request.url?.absoluteString, "https://wordpress.org/hello/world") + + request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/hello")!) + .append(percentEncodedPath: "world") + .build() + XCTAssertEqual(request.url?.absoluteString, "https://wordpress.org/hello/world") + + request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/hello")!) + .append(percentEncodedPath: "/world") + .build() + XCTAssertEqual(request.url?.absoluteString, "https://wordpress.org/hello/world") + } + + func testQueryOverride() { + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .query(name: "foo", value: "hello", override: true) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=hello" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .query(name: "foo", value: "hello", override: false) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar&foo=hello" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar") + .query(name: "foo", value: "hello") + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar&foo=hello" + ) + } + + func testQueryOverrideMany() { + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .append(query: [URLQueryItem(name: "foo", value: "hello")], override: true) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=hello" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar", override: true) + .append(query: [URLQueryItem(name: "foo", value: "hello")], override: false) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar&foo=hello" + ) + + try XCTAssertEqual( + HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(name: "foo", value: "bar") + .append(query: [URLQueryItem(name: "foo", value: "hello")]) + .build() + .url? + .absoluteString, + "https://wordpress.org?foo=bar&foo=hello" + ) + } + + @available(iOS 16.0, *) + func testSetQueryWithDictionary() throws { + let query = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(HTTPRequestBuilderTests.nestedParameters) + .build() + .url? + .query(percentEncoded: false)? + .split(separator: "&") + .reduce(into: Set()) { $0.insert(String($1)) } + ?? [] + + XCTAssertEqual(query.count, HTTPRequestBuilderTests.nestedParametersEncoded.count) + + for item in HTTPRequestBuilderTests.nestedParametersEncoded { + XCTAssertTrue(query.contains(item), "Missing query item: \(item)") + } + } + + func testDefaultQuery() throws { + let builder = HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .query(defaults: [URLQueryItem(name: "locale", value: "en")]) + + try XCTAssertEqual(builder.build().url?.query, "locale=en") + try XCTAssertEqual(builder.query(name: "locale", value: "zh").build().url?.query, "locale=zh") + try XCTAssertEqual(builder.query(name: "foo", value: "bar").build().url?.query, "locale=zh&foo=bar") + } + + func testDefaultQueryDoesNotOverriedQueryItemInOriginalURL() throws { + let url = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/hello?locale=foo")!) + .query(defaults: [URLQueryItem(name: "locale", value: "en")]) + .build() + .url + + XCTAssertEqual(url?.query, "locale=foo") + } + + func testJSONBody() throws { + var request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(json: 42) + .build() + XCTAssertTrue(request.value(forHTTPHeaderField: "Content-Type")?.contains("application/json") == true) + try XCTAssertEqual(XCTUnwrap(request.httpBodyText), "42") + + request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(json: ["foo": "bar"]) + .build() + try XCTAssertEqual(XCTUnwrap(request.httpBodyText), #"{"foo":"bar"}"#) + } + + func testJSONBodyWithEncodable() throws { + struct Body: Encodable { + var foo: String + } + let body = Body(foo: "bar") + + let request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(json: body) + .build() + XCTAssertTrue(request.value(forHTTPHeaderField: "Content-Type")?.contains("application/json") == true) + try XCTAssertEqual(XCTUnwrap(request.httpBodyText), #"{"foo":"bar"}"#) + } + + func testFormBody() throws { + let request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(form: ["name": "Foo Bar"]) + .build() + XCTAssertTrue(request.value(forHTTPHeaderField: "Content-Type")?.contains("application/x-www-form-urlencoded") == true) + try XCTAssertEqual(XCTUnwrap(request.httpBodyText), #"name=Foo%20Bar"#) + } + + func testFormWithSpecialCharacters() throws { + let request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(form: ["text": ":#[]@!$&'()*+,;="]) + .build() + try XCTAssertEqual(XCTUnwrap(request.httpBodyText), "text=%3A%23%5B%5D%40%21%24%26%27%28%29%2A%2B%2C%3B%3D") + } + + func testFormWithRandomSpecialCharacters() throws { + let asciis = "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + let unicodes = "表情符号😇🤔✅😎" + let randomText: () -> String = { + let chars = (1...10).map { _ in (asciis + unicodes).randomElement()! } + return String(chars) + } + // Generate a form (key-value pairs) with random characters. + let form = [ + randomText(): randomText(), + randomText(): randomText(), + randomText(): randomText(), + ] + + let request = try HTTPRequestBuilder(url: URL(string: "https://wordpress.org")!) + .method(.post) + .body(form: form) + .build() + let encoded = try XCTUnwrap(request.httpBodyText) + + // Decoding the url-encoded form, whose format should be "=&=&...". + let keyValuePairs = try encoded.split(separator: "&").map { pair in + XCTAssertEqual(pair.firstIndex(of: "="), pair.lastIndex(of: "="), "There should be only one '=' in a key-value pair") + + let firstIndex = try XCTUnwrap(pair.firstIndex(of: "=")) + let key = pair[pair.startIndex.." + XCTAssertTrue(xmlrpcContent.contains(filePart)) + } + +} diff --git a/WordPressKit/Tests/CoreAPITests/MultipartFormTests.swift b/WordPressKit/Tests/CoreAPITests/MultipartFormTests.swift new file mode 100644 index 000000000000..c966ff9db12c --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/MultipartFormTests.swift @@ -0,0 +1,169 @@ +import Foundation +import Alamofire +import XCTest +import CryptoKit +#if SWIFT_PACKAGE +@testable import CoreAPI +#else +@testable import WordPressKit +#endif + +class MutliparFormDataTests: XCTestCase { + struct Form: Codable { + struct Field: Codable { + var name: String + var content: String + } + + var fields: [Field] + + static func random(numberOfFields: Int = 10) -> Form { + let randomText: () -> String = { String(format: "%08x", Int.random(in: Int.min...Int.max)) } + let fields = (1...numberOfFields).map { _ in + Field(name: randomText(), content: randomText()) + } + return Form(fields: fields) + } + + func formDataUsingAlamofire() throws -> Data { + let formData = MultipartFormData(boundary: "testboundary") + for field in fields { + formData.append(field.content.data(using: .utf8)!, withName: field.name) + } + return try formData.encode() + } + + func formData() throws -> Data { + try fields + .map { + MultipartFormField(text: $0.content, name: $0.name) + } + .multipartFormDataStream(boundary: "testboundary") + .readToEnd() + } + } + + func testRandomForm() throws { + let tempDir = FileManager.default.temporaryDirectory + let testData = tempDir.appendingPathComponent("test-form.json") + let afOutput = tempDir.appendingPathComponent("test-form.af.txt") + let wpOutput = tempDir.appendingPathComponent("test-form.wp.txt") + + let form = Form.random() + try JSONEncoder().encode(form).write(to: testData) + + let afEncoded = try form.formDataUsingAlamofire() + try afEncoded.write(to: afOutput) + + let encoded = try form.formData() + try encoded.write(to: wpOutput) + + add(XCTAttachment(contentsOfFile: testData)) + add(XCTAttachment(contentsOfFile: afOutput)) + add(XCTAttachment(contentsOfFile: wpOutput)) + + XCTAssertEqual(afEncoded, encoded) + } + + func testPlainText() throws { + let af = MultipartFormData() + af.append("hello".data(using: .utf8)!, withName: "world") + af.append("foo".data(using: .utf8)!, withName: "bar") + af.append("the".data(using: .utf8)!, withName: "end") + let afEncoded = try af.encode() + + let fields = [ + MultipartFormField(text: "hello", name: "world"), + MultipartFormField(text: "foo", name: "bar"), + MultipartFormField(text: "the", name: "end"), + ] + let encoded = try fields.multipartFormDataStream(boundary: af.boundary).readToEnd() + + XCTAssertEqual(afEncoded, encoded) + } + + func testEmptyForm() throws { + let formData = try [].multipartFormDataStream(boundary: "test").readToEnd() + XCTAssertTrue(formData.isEmpty) + } + + func testOneField() throws { + let af = MultipartFormData() + af.append("hello".data(using: .utf8)!, withName: "world") + let afEncoded = try af.encode() + + let formData = try [MultipartFormField(text: "hello", name: "world")].multipartFormDataStream(boundary: af.boundary).readToEnd() + + XCTAssertEqual(afEncoded, formData) + } + + func testUploadSmallFile() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileContent = Data(repeating: Character("a").asciiValue!, count: 20_000_000) + let filePath = tempDir.appendingPathComponent("file.png") + let resultPath = tempDir.appendingPathComponent("result.txt") + try fileContent.write(to: filePath) + defer { + try? FileManager.default.removeItem(at: filePath) + try? FileManager.default.removeItem(at: resultPath) + } + + let fields = [ + MultipartFormField(text: "123456", name: "site"), + try MultipartFormField(fileAtPath: filePath.path, name: "media", filename: "file.png", mimeType: "image/png"), + ] + let formData = try fields.multipartFormDataStream(boundary: "testboundary").readToEnd() + try formData.write(to: resultPath) + + // Reminder: Check the multipart form file before updating this assertion + XCTAssertTrue(SHA256.hash(data: formData).description.contains("8c985fdc03e75389b85a74996504aa10e0b054b1b5f771bd1ba0db155281bb53")) + } + + func testUploadLargeFile() throws { + let tempDir = FileManager.default.temporaryDirectory + let fileContent = Data(repeating: Character("a").asciiValue!, count: 50_000_000) + let filePath = tempDir.appendingPathComponent("file.png") + let resultPath = tempDir.appendingPathComponent("result.txt") + try fileContent.write(to: filePath) + defer { + try? FileManager.default.removeItem(at: filePath) + try? FileManager.default.removeItem(at: resultPath) + } + + let fields = [ + MultipartFormField(text: "123456", name: "site"), + try MultipartFormField(fileAtPath: filePath.path, name: "media", filename: "file.png", mimeType: "image/png"), + ] + let formData = try fields.multipartFormDataStream(boundary: "testboundary").readToEnd() + try formData.write(to: resultPath) + + // Reminder: Check the multipart form file before updating this assertion + XCTAssertTrue(SHA256.hash(data: formData).description.contains("2cedb35673a6982453a6e8e5ca901feabf92250630cdfabb961a03467f28bc8e")) + } + +} + +extension Either { + func readToEnd() -> Data { + map( + left: { $0 }, + right: { InputStream(url: $0)?.readToEnd() ?? Data() } + ) + } +} + +extension InputStream { + func readToEnd() -> Data { + open() + defer { close() } + + var data = Data() + let maxLength = 1024 + var buffer = [UInt8](repeating: 0, count: maxLength) + while hasBytesAvailable { + let bytes = read(&buffer, maxLength: maxLength) + data.append(buffer, count: bytes) + } + return data + } +} diff --git a/WordPressKit/Tests/CoreAPITests/NonceRetrievalTests.swift b/WordPressKit/Tests/CoreAPITests/NonceRetrievalTests.swift new file mode 100644 index 000000000000..c27c8cd1d2fb --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/NonceRetrievalTests.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest +import OHHTTPStubs +#if SWIFT_PACKAGE +@testable import CoreAPI +import OHHTTPStubsSwift +#else +@testable import WordPressKit +#endif + +class NonceRetrievalTests: XCTestCase { + + static let nonce = "leg1tn0nce" + static let siteURL = URL(string: "https://test.com")! + static let siteLoginURL = URL(string: "https://test.com/wp-login.php")! + static let siteAdminURL = URL(string: "https://test.com/wp-admin/")! + static let newPostURL = URL(string: "https://test.com/wp-admin/post-new.php")! + static let ajaxURL = URL(string: "https://test.com/wp-admin/admin-ajax.php?action=rest-nonce")! + + override func tearDown() { + super.tearDown() + HTTPStubs.removeAllStubs() + } + + func testUsingNewPostPage() async { + stubLoginRedirect(dest: Self.newPostURL) + stubNewPostPage(statusCode: 200) + + let nonce = await NonceRetrievalMethod.newPostScrap.retrieveNonce( + username: "test", + password: .init("pass"), + loginURL: Self.siteLoginURL, + adminURL: Self.siteAdminURL, + using: URLSession(configuration: .ephemeral) + ) + XCTAssertEqual(nonce, Self.nonce) + } + + func testUsingRESTNonceAjax() async { + stubLoginRedirect(dest: Self.ajaxURL) + stubAjax(statusCode: 200) + + let nonce = await NonceRetrievalMethod.ajaxNonceRequest.retrieveNonce( + username: "test", + password: .init("pass"), + loginURL: Self.siteLoginURL, + adminURL: Self.siteAdminURL, + using: URLSession(configuration: .ephemeral) + ) + XCTAssertEqual(nonce, Self.nonce) + } + + private func stubLoginRedirect(dest: URL) { + stub(condition: isAbsoluteURLString(Self.siteLoginURL.absoluteString)) { _ in + HTTPStubsResponse(data: Data(), statusCode: 302, headers: ["Location": dest.absoluteString]) + } + } + + private func stubNewPostPage(nonceScript: String? = nil, statusCode: Int32) { + let script = nonceScript ?? """ + wp.apiFetch.nonceMiddleware = wp.apiFetch.createNonceMiddleware( "\(Self.nonce)" ); + wp.apiFetch.use( wp.apiFetch.nonceMiddleware ); + wp.apiFetch.use( wp.apiFetch.mediaUploadMiddleware ); + """ + let html = "\n\(script)\n" + stub(condition: isAbsoluteURLString(Self.newPostURL.absoluteString)) { _ in + HTTPStubsResponse(data: html.data(using: .utf8)!, statusCode: statusCode, headers: nil) + } + } + + private func stubAjax(statusCode: Int32) { + stub(condition: isAbsoluteURLString(Self.ajaxURL.absoluteString)) { _ in + HTTPStubsResponse(data: (statusCode == 200 ? Self.nonce : "...").data(using: .utf8)!, statusCode: statusCode, headers: nil) + } + } + +} diff --git a/WordPressKit/Tests/CoreAPITests/RSDParserTests.swift b/WordPressKit/Tests/CoreAPITests/RSDParserTests.swift new file mode 100644 index 000000000000..700c283ac3e5 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/RSDParserTests.swift @@ -0,0 +1,50 @@ +import Foundation +import XCTest +#if SWIFT_PACKAGE +@testable import CoreAPI +#else +@testable import WordPressKit +#endif + +class RSDParserTests: XCTestCase { + + func testSuccess() throws { + // Grabbed from https://developer.wordpress.org/xmlrpc.php?rsd + let xml = """ + + + WordPress + https://wordpress.org/ + https://developer.wordpress.org + + + + + + + + + + """ + let parser = try XCTUnwrap(WordPressRSDParser(xmlString: xml)) + try XCTAssertEqual(parser.parsedEndpoint(), "https://developer.wordpress.org/xmlrpc.php") + } + + func testWordPressEntryOnly() throws { + // Grabbed from https://developer.wordpress.org/xmlrpc.php?rsd, but removing all other api links. + let xml = """ + + + WordPress + https://wordpress.org/ + https://developer.wordpress.org + + + + + """ + let parser = try XCTUnwrap(WordPressRSDParser(xmlString: xml)) + try XCTAssertEqual(parser.parsedEndpoint(), "https://developer.wordpress.org/xmlrpc.php") + } + +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-invalid.html b/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-invalid.html new file mode 100644 index 000000000000..6c25e2cac0c6 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-invalid.html @@ -0,0 +1,7 @@ + + + + website + + 👋 + diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-mobile-plugin-redirect.html b/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-mobile-plugin-redirect.html new file mode 100644 index 000000000000..fc8f70eaf3af --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/HTML/xmlrpc-response-mobile-plugin-redirect.html @@ -0,0 +1,8 @@ + + + + + website + + 👋 + diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDToken2FANeededSuccess.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDToken2FANeededSuccess.json new file mode 100644 index 000000000000..366fa8835ceb --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDToken2FANeededSuccess.json @@ -0,0 +1,13 @@ +{ + "data": { + "two_step_nonce_authenticator": "two_step_nonce_authenticator", + "two_step_nonce": "two_step_nonce", + "two_step_nonce_sms": "two_step_nonce_sms", + "two_step_nonce_backup": "two_step_nonce_backup", + "two_step_nonce_webauthn": "two_step_nonce_webauthn", + "two_step_notification_sent": "two_step_notification_sent", + "two_step_supported_auth_types": "two_step_supported_auth_types", + "phone_number": "phone_number", + "user_id": 1 + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenBearerTokenSuccess.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenBearerTokenSuccess.json new file mode 100644 index 000000000000..fe81d177cb99 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenBearerTokenSuccess.json @@ -0,0 +1,13 @@ +{ + "data": { + "two_step_nonce_authenticator": "two_step_nonce_authenticator", + "two_step_nonce": "two_step_nonce", + "two_step_nonce_sms": "two_step_nonce_sms", + "two_step_nonce_backup": "two_step_nonce_backup", + "two_step_notification_sent": "two_step_notification_sent", + "two_step_supported_auth_types": "two_step_supported_auth_types", + "phone_number": "phone_number", + "bearer_token": "bearer_token", + "user_id": "user_id" + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenExistingUserNeedsConnection.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenExistingUserNeedsConnection.json new file mode 100644 index 000000000000..1a8f6f3a3e85 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComAuthenticateWithIDTokenExistingUserNeedsConnection.json @@ -0,0 +1,10 @@ +{ + "data": { + "errors": [{ + "code":"user_exists", + "message":"User exists but not connected" + }], + "two_step_nonce": "two_step_nonce", + "email": "email", + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthAuthenticateSignature.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthAuthenticateSignature.json new file mode 100644 index 000000000000..8307598d7d66 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthAuthenticateSignature.json @@ -0,0 +1,12 @@ +{ + "success": true, + "data": { + "bearer_token": "bearer_token", + "token_links": [ + "https:\/\/jetpack.com\/remote-login.php?wpcom_rem...", + "https:\/\/fieldguide.automattic.com\/remote-logi...", + "https:\/\/learn.a8c.com\/remote-login.php?wpcom_remote_login...", + "https:\/\/a8c.tv\/remote-login.php?wpcom_remote_login=vali..." + ] + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeeds2FAFail.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeeds2FAFail.json new file mode 100644 index 000000000000..6bfbf7d62cc9 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeeds2FAFail.json @@ -0,0 +1,4 @@ +{ + "error_description": "Please enter the verification code generated by your Authenticator mobile application.", + "error": "needs_2fa" +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeedsWebauthnMFA.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeedsWebauthnMFA.json new file mode 100644 index 000000000000..d68917b34253 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthNeedsWebauthnMFA.json @@ -0,0 +1,7 @@ +{ + "success": true, + "data": { + "user_id": 1234, + "two_step_nonce_webauthn": "two_step_nonce_webauthn", + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthRequestChallenge.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthRequestChallenge.json new file mode 100644 index 000000000000..0442814a0ae1 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthRequestChallenge.json @@ -0,0 +1,33 @@ +{ + "success": true, + "data": { + "challenge": "challenge", + "rpId": "wordpress.com", + "allowCredentials": [ + { + "type": "public-key", + "id": "credential-id", + "transports": [ + "usb", + "nfc", + "ble", + "hybrid", + "internal" + ] + }, + { + "type": "public-key", + "id": "credential-id-2", + "transports": [ + "usb", + "nfc", + "ble", + "hybrid", + "internal" + ] + } + ], + "timeout": 60000, + "two_step_nonce": "two_step_nonce" + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthSuccess.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthSuccess.json new file mode 100644 index 000000000000..5bc5c0a69b0c --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthSuccess.json @@ -0,0 +1,7 @@ +{ + "blog_id": 0, + "scope": "global", + "blog_url": null, + "token_type": "bearer", + "access_token": "fakeToken" +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthWrongPasswordFail.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthWrongPasswordFail.json new file mode 100644 index 000000000000..b6f3795019ff --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComOAuthWrongPasswordFail.json @@ -0,0 +1,4 @@ +{ + "error_description": "Incorrect username or password.", + "error": "invalid_request" +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidInput.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidInput.json new file mode 100644 index 000000000000..473d09f4429b --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidInput.json @@ -0,0 +1,4 @@ +{ + "message": "No media provided in input.", + "error": "invalid_input" +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidJSON.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidJSON.json new file mode 100644 index 000000000000..9b4c74354223 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailInvalidJSON.json @@ -0,0 +1,4 @@ + + "message": "The OAuth2 token is invalid.", + "error": "invalid_token" +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailRequestInvalidToken.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailRequestInvalidToken.json new file mode 100644 index 000000000000..4d2dc0d0b16e --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailRequestInvalidToken.json @@ -0,0 +1,4 @@ +{ + "message": "The OAuth2 token is invalid.", + "error": "invalid_token" +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailThrottled.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailThrottled.json new file mode 100644 index 000000000000..89bd200ebf60 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailThrottled.json @@ -0,0 +1,3 @@ + +Limit reached + \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailUnauthorized.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailUnauthorized.json new file mode 100644 index 000000000000..0dd0fc043f69 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiFailUnauthorized.json @@ -0,0 +1,4 @@ +{ + "message": "User cannot upload media.", + "error": "unauthorized" +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMedia.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMedia.json new file mode 100644 index 000000000000..e2991dd69884 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMedia.json @@ -0,0 +1,868 @@ +{ + "found": 429, + "media": [ + { + "ID": 969, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png", + "date": "2015-04-19T16:51:24+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb12.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb12", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/969", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/969\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 967, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png", + "date": "2015-04-19T16:51:22+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb11.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb11", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/967", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/967\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 965, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png", + "date": "2015-04-19T16:50:20+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb10.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb10", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/965", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/965\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 963, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png", + "date": "2015-04-19T16:50:18+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb9.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb9", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/963", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/963\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 961, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png", + "date": "2015-04-19T16:49:27+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb8.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb8", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/961", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/961\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 959, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png", + "date": "2015-04-19T16:49:25+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb7.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb7", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb7.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/959", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/959\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 957, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png", + "date": "2015-04-19T16:48:47+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb6.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb6", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb6.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/957", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/957\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 956, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png", + "date": "2015-04-19T16:48:46+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb15.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb15", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb15.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/956", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/956\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 955, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png", + "date": "2015-04-19T16:48:45+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb5.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb5", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb5.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/955", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/955\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 953, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png", + "date": "2015-04-19T16:47:36+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb4.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb4", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb4.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/953", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/953\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 952, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png", + "date": "2015-04-19T16:47:35+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb13.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb13", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb13.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/952", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/952\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 951, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png", + "date": "2015-04-19T16:47:34+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb3.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb3", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb3.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/951", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/951\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 950, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png", + "date": "2015-04-19T16:47:33+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb12.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb12", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb12.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/950", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/950\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 949, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png", + "date": "2015-04-19T16:47:10+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb2.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb2", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb2.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/949", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/949\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 948, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png", + "date": "2015-04-19T16:47:09+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb11.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb11", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb11.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/948", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/948\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 947, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png", + "date": "2015-04-19T16:45:36+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb1.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb1", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb1.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/947", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/947\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 946, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png", + "date": "2015-04-19T16:45:35+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb10.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb10", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb10.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/946", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/946\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 945, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png", + "date": "2015-04-19T16:45:34+00:00", + "post_ID": 0, + "author_ID": 0, + "file": "codeispoetry-rgb.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/945", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/945\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 944, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png", + "date": "2015-04-19T16:45:32+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb9.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb9", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb9.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/944", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/944\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + }, + { + "ID": 943, + "URL": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png", + "guid": "http:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png", + "date": "2015-04-19T16:45:13+00:00", + "post_ID": 0, + "author_ID": 78972699, + "file": "codeispoetry-rgb8.png", + "mime_type": "image\/png", + "extension": "png", + "title": "codeispoetry-rgb8", + "caption": "", + "description": "", + "alt": "", + "thumbnails": { + "thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=150", + "medium": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=300", + "large": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=500", + "post-thumbnail": "https:\/\/apiexamples.files.wordpress.com\/2015\/04\/codeispoetry-rgb8.png?w=500&h=34&crop=1" + }, + "height": 34, + "width": 500, + "exif": { + "aperture": 0, + "credit": "", + "camera": "", + "caption": "", + "created_timestamp": 0, + "copyright": "", + "focal_length": 0, + "iso": 0, + "shutter_speed": 0, + "title": "", + "orientation": 0 + }, + "meta": { + "links": { + "self": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/943", + "help": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409\/media\/943\/help", + "site": "https:\/\/public-api.wordpress.com\/rest\/v1.1\/sites\/82974409" + } + } + } + ], + "meta": { + "next_page": "value=2015-04-19T16%3A45%3A13%2B00%3A00&id=943" + } +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMultipleErrors.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMultipleErrors.json new file mode 100644 index 000000000000..a23c5e6f1e23 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComRestApiMultipleErrors.json @@ -0,0 +1,8 @@ +{ + "errors":[ + { + "message": "No media provided in input.", + "error": "upload_error" + } + ] +} \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComSocial2FACodeSuccess.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComSocial2FACodeSuccess.json new file mode 100644 index 000000000000..fe81d177cb99 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/WordPressComSocial2FACodeSuccess.json @@ -0,0 +1,13 @@ +{ + "data": { + "two_step_nonce_authenticator": "two_step_nonce_authenticator", + "two_step_nonce": "two_step_nonce", + "two_step_nonce_sms": "two_step_nonce_sms", + "two_step_nonce_backup": "two_step_nonce_backup", + "two_step_notification_sent": "two_step_notification_sent", + "two_step_supported_auth_types": "two_step_supported_auth_types", + "phone_number": "phone_number", + "bearer_token": "bearer_token", + "user_id": "user_id" + } +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/me-settings-success.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/me-settings-success.json new file mode 100644 index 000000000000..19439786dafb --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/me-settings-success.json @@ -0,0 +1,47 @@ +{ + "enable_translator": false, + "surprise_me": true, + "post_post_flag": true, + "holidaysnow": true, + "user_login": "jimthetester", + "password": "", + "display_name": "Jim Tester", + "first_name": "Jim", + "last_name": "Tester", + "description": "Happy go lucky kind of tester.", + "user_email": "jimthetester@thetestemail.org", + "user_email_change_pending": false, + "new_user_email": "", + "user_URL": "http:\/\/jimthetester.blog", + "language": "en", + "avatar_URL": "https:\/\/2.gravatar.com\/avatar\/5c78d333444a3c12345ed8ff0e567890?s=200&d=mm", + "primary_site_ID": 321, + "comment_like_notification": true, + "mentions_notification": true, + "subscription_delivery_email_default": "instantly", + "subscription_delivery_jabber_default": false, + "subscription_delivery_mail_option": "html", + "subscription_delivery_day": 1, + "subscription_delivery_hour": 6, + "subscription_delivery_email_blocked": true, + "two_step_enabled": true, + "two_step_sms_enabled": false, + "two_step_backup_codes_printed": true, + "two_step_sms_country": "US", + "two_step_sms_phone_number": "2623536462", + "user_login_can_be_changed": false, + "calypso_preferences": { + "editor-mode": "html", + "editorAdvancedVisible": true, + "mediaModalGalleryInstructionsDismissed": true, + "recentSites": [ + 1234, + 56789, + 100234 + ] + }, + "jetpack_connect": [], + "is_desktop_app_user": true, + "locale_variant": false, + "tracks_opt_out": false +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-forbidden.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-forbidden.json new file mode 100644 index 000000000000..a40b581cbfd1 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-forbidden.json @@ -0,0 +1,7 @@ +{ + "code": "rest_forbidden", + "data": { + "status": 401 + }, + "message": "Sorry, you are not allowed to do that." +} diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-pages.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-pages.json new file mode 100644 index 000000000000..9c89782e5615 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-pages.json @@ -0,0 +1,729 @@ +[{ + "id": 457, + "date": "2019-02-06T00:37:47", + "date_gmt": "2019-02-06T00:37:47", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=457" + }, + "modified": "2019-02-06T00:45:13", + "modified_gmt": "2019-02-06T00:45:13", + "slug": "home", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/", + "title": { + "rendered": "Home" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 0, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-7n", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/457" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=457" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/457\/revisions" + }], + "predecessor-version": [{ + "id": 458, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/457\/revisions\/458" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=457" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 385, + "date": "2018-07-09T21:23:48", + "date_gmt": "2018-07-09T21:23:48", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=385" + }, + "modified": "2018-11-26T07:14:22", + "modified_gmt": "2018-11-26T07:14:22", + "slug": "counter", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/download\/counter\/", + "title": { + "rendered": "Counter" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 371, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-download-counter.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-6d", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/385" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=385" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/385\/revisions" + }], + "predecessor-version": [{ + "id": 386, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/385\/revisions\/386" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=385" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 379, + "date": "2018-07-03T02:03:10", + "date_gmt": "2018-07-03T02:03:10", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=379" + }, + "modified": "2018-11-26T07:14:22", + "modified_gmt": "2018-11-26T07:14:22", + "slug": "source", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/download\/source\/", + "title": { + "rendered": "Source Code" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 371, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-download-source.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-67", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/379" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=379" + }], + "version-history": [{ + "count": 2, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/379\/revisions" + }], + "predecessor-version": [{ + "id": 381, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/379\/revisions\/381" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=379" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 375, + "date": "2018-07-03T02:01:07", + "date_gmt": "2018-07-03T02:01:07", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=375" + }, + "modified": "2018-11-26T07:14:22", + "modified_gmt": "2018-11-26T07:14:22", + "slug": "beta-nightly", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/download\/beta-nightly\/", + "title": { + "rendered": "Beta\/Nightly" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 371, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-download-beta-nightly.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-63", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/375" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=375" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/375\/revisions" + }], + "predecessor-version": [{ + "id": 376, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/375\/revisions\/376" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=375" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 373, + "date": "2018-07-02T23:57:35", + "date_gmt": "2018-07-02T23:57:35", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=373" + }, + "modified": "2018-11-26T07:14:22", + "modified_gmt": "2018-11-26T07:14:22", + "slug": "releases", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/download\/releases\/", + "title": { + "rendered": "Releases" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 371, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-download-releases.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-61", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/373" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=373" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/373\/revisions" + }], + "predecessor-version": [{ + "id": 374, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/373\/revisions\/374" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=373" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 371, + "date": "2018-07-02T23:57:04", + "date_gmt": "2018-07-02T23:57:04", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=371" + }, + "modified": "2018-11-26T07:14:22", + "modified_gmt": "2018-11-26T07:14:22", + "slug": "download", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/download\/", + "title": { + "rendered": "Download" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 0, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-download.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-5Z", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=371" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371\/revisions" + }], + "predecessor-version": [{ + "id": 372, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/371\/revisions\/372" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=371" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 329, + "date": "2018-05-30T06:49:44", + "date_gmt": "2018-05-30T06:49:44", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=329" + }, + "modified": "2018-05-30T06:49:44", + "modified_gmt": "2018-05-30T06:49:44", + "slug": "data-erasure-request", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/about\/privacy\/data-erasure-request\/", + "title": { + "rendered": "Data Erasure Request" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 196012, + "featured_media": 0, + "parent": 259, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-about-privacy-data-erasure-request.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-5j", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/329" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/196012" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=329" + }], + "version-history": [{ + "count": 2, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/329\/revisions" + }], + "predecessor-version": [{ + "id": 331, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/329\/revisions\/331" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/259" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=329" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 315, + "date": "2018-05-28T09:13:38", + "date_gmt": "2018-05-28T09:13:38", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=315" + }, + "modified": "2019-06-21T07:58:29", + "modified_gmt": "2019-06-21T07:58:29", + "slug": "data-export-request", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/about\/privacy\/data-export-request\/", + "title": { + "rendered": "Data Export Request" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 259, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-about-privacy-data-export-request.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-55", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/315" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=315" + }], + "version-history": [{ + "count": 1, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/315\/revisions" + }], + "predecessor-version": [{ + "id": 316, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/315\/revisions\/316" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/259" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=315" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 309, + "date": "2018-05-25T04:16:41", + "date_gmt": "2018-05-25T04:16:41", + "guid": { + "rendered": "https:\/\/wordpress.org\/?page_id=309" + }, + "modified": "2018-05-28T11:14:32", + "modified_gmt": "2018-05-28T11:14:32", + "slug": "cookies", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/about\/privacy\/cookies\/", + "title": { + "rendered": "Cookie Policy" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 259, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-about-privacy-cookies.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-4Z", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/309" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=309" + }], + "version-history": [{ + "count": 2, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/309\/revisions" + }], + "predecessor-version": [{ + "id": 311, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/309\/revisions\/311" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/259" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=309" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}, { + "id": 281, + "date": "2018-03-28T03:08:38", + "date_gmt": "2018-03-28T03:08:38", + "guid": { + "rendered": "https:\/\/wordpress.org\/about\/accessibility\/" + }, + "modified": "2018-03-28T03:08:38", + "modified_gmt": "2018-03-28T03:08:38", + "slug": "accessibility", + "status": "publish", + "type": "page", + "link": "https:\/\/wordpress.org\/about\/accessibility\/", + "title": { + "rendered": "Accessibility" + }, + "content": { + "rendered": "", + "protected": false + }, + "excerpt": { + "rendered": "", + "protected": false + }, + "author": 5911429, + "featured_media": 0, + "parent": 251, + "menu_order": 0, + "comment_status": "closed", + "ping_status": "closed", + "template": "page-about-accessibility.php", + "meta": { + "spay_email": "" + }, + "jetpack_shortlink": "https:\/\/wp.me\/P1OHUb-4x", + "_links": { + "self": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/281" + }], + "collection": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages" + }], + "about": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/types\/page" + }], + "author": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/users\/5911429" + }], + "replies": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/comments?post=281" + }], + "version-history": [{ + "count": 0, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/281\/revisions" + }], + "up": [{ + "embeddable": true, + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/pages\/251" + }], + "wp:attachment": [{ + "href": "https:\/\/wordpress.org\/wp-json\/wp\/v2\/media?parent=281" + }], + "curies": [{ + "name": "wp", + "href": "https:\/\/api.w.org\/{rel}", + "templated": true + }] + } +}] \ No newline at end of file diff --git a/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-reusable-blocks.json b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-reusable-blocks.json new file mode 100644 index 000000000000..5e818f84e781 --- /dev/null +++ b/WordPressKit/Tests/CoreAPITests/Stubs/JSON/wp-reusable-blocks.json @@ -0,0 +1,50 @@ +{ + "id": 6, + "date": "2021-02-10T11:51:53", + "date_gmt": "2021-02-10T11:51:53", + "guid": { + "rendered": "https:\/\/test-site.org\/2021\/02\/10\/untitled-reusable-block\/", + "raw": "https:\/\/test-site.org\/2021\/02\/10\/untitled-reusable-block\/" + }, + "modified": "2021-02-10T12:31:39", + "modified_gmt": "2021-02-10T12:31:39", + "password": "", + "slug": "untitled-reusable-block", + "status": "publish", + "type": "wp_block", + "link": "https:\/\/test-site.org\/2021\/02\/10\/untitled-reusable-block\/", + "title": { + "raw": "A resuable block" + }, + "content": { + "raw": "\n

Some text<\/p>\n\n\n\n

`kdkli{nYp831J`q8 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 literal 0 HcmV?d00001 diff --git a/WordPressAuthenticator/Sources/Resources/SupportedEmailClients/EmailClients.plist b/WordPressAuthenticator/Sources/Resources/SupportedEmailClients/EmailClients.plist new file mode 100644 index 000000000000..e085bae01f0e --- /dev/null +++ b/WordPressAuthenticator/Sources/Resources/SupportedEmailClients/EmailClients.plist @@ -0,0 +1,18 @@ + + + + + gmail + googlegmail:// + airmail + airmail:// + msOutlook + ms-outlook:// + spark + readdle-spark:// + yahooMail + ymail:// + fastmail + fastmail:// + + diff --git a/WordPressAuthenticator/Sources/Services/LoginFacade.h b/WordPressAuthenticator/Sources/Services/LoginFacade.h new file mode 100644 index 000000000000..4c64454e77d5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/LoginFacade.h @@ -0,0 +1,174 @@ +#import +#import + + +NS_ASSUME_NONNULL_BEGIN + +@class LoginFields; +@class SocialLogin2FANonceInfo; +@protocol WordPressComOAuthClientFacadeProtocol; +@protocol WordPressXMLRPCAPIFacade; +@protocol LoginFacadeDelegate; + + +/** + * This protocol represents a class that handles the signing in to a self hosted/.com site. + */ +@protocol LoginFacade + +/** + * This method initializes the LoginFacade instance. + * + * @param dotcomClientID WordPress.com Client ID. + * @param dotcomSecret WordPress.com Secret. + * @param userAgent UserAgent string to be used interacting with a remote endpoint. + * + * @note When this class is Swifted, we can (probably) just query WordPressAuthenticator. + */ +- (instancetype)initWithDotcomClientID:(NSString *)dotcomClientID dotcomSecret:(NSString *)dotcomSecret userAgent:(NSString *)userAgent; + +/** + * This method will attempt to sign in to a self hosted/.com site. + * XMLRPC endpoint discover is performed. + * + * @param loginFields the fields representing the site we are attempting to login to. + */ +- (void)signInWithLoginFields:(LoginFields *)loginFields; + +/** + * This method will attempt to sign in to a self hosted/.com site. + * The XML-RPC endpoint should be present in the loginFields.siteUrl field. + * + * @param loginFields the fields representing the site we are attempting to login to. + */ +- (void)loginToSelfHosted:(LoginFields *)loginFields; + +/** + * Social login. + * + * @param token Social id token. + */ +- (void)loginToWordPressDotComWithSocialIDToken:(NSString *)token + service:(NSString *)service; + +/** + * Social login via a social account with 2FA using a nonce. + * + * @param userID WordPress.com User ID + * @param authType Indicates the Kind of Authentication we're performing (sms / authenticator / etc). + * @param twoStepCode Multifactor Code. + * @param twoStepNonce Two Step Nonce. + */ +- (void)loginToWordPressDotComWithUser:(NSInteger)userID + authType:(NSString *)authType + twoStepCode:(NSString *)twoStepCode + twoStepNonce:(NSString *)twoStepNonce; + + +/** + * A delegate with a few methods that indicate various aspects of the login process + */ +@property (nonatomic, weak) id delegate; + +/** + * A class that handles the login to sites requiring oauth(primarily .com sites) + */ +@property (nonatomic, strong) id wordpressComOAuthClientFacade; + +/** + * A class that handles the login to self hosted sites + */ +@property (nonatomic, strong) id wordpressXMLRPCAPIFacade; + +@end + +/** + * This class handles the signing in to a self hosted/.com site. + */ +@interface LoginFacade : NSObject + +@end + +/** + * Protocol with a few methods that indicate various aspects of the login process. + */ +@protocol LoginFacadeDelegate + +@optional + +/** + * This is called when we need to indicate to the a messagea about the current login (e.g. "Signing In", "Authenticating", "Syncing", etc.) + * + * @param message the message to display to the user. + */ +- (void)displayLoginMessage:(NSString *)message; + +/** + * This is called when the initial login failed because we need a 2fa code. + */ +- (void)needsMultifactorCode; + +/** + * This is called when the initial login failed because we need a 2fa code for a social login. + * + * @param userID the WPCom userID of the user logging in. + * @param nonceInfo an object containing information about available 2fa nonce options. + */ +- (void)needsMultifactorCodeForUserID:(NSInteger)userID andNonceInfo:(SocialLogin2FANonceInfo *)nonceInfo; + +/** + * This is called when there's been an error and we want to inform the user. + * + * @param error the error in question. + */ +- (void)displayRemoteError:(NSError *)error; + +/** + * Called when finished logging into a self hosted site + * + * @param username username of the site + * @param password password of the site + * @param xmlrpc the xmlrpc url of the site + * @param options the options dictionary coming back from the `wp.getOptions` method. + */ +- (void)finishedLoginWithUsername:(NSString *)username password:(NSString *)password xmlrpc:(NSString *)xmlrpc options:(NSDictionary * )options; + + +/** + * Called when finished logging in to a WordPress.com site + * + * @param authToken authToken to be used to access the site + * @param requiredMultifactorCode whether the login required a 2fa code + */ +- (void)finishedLoginWithAuthToken:(NSString *)authToken requiredMultifactorCode:(BOOL)requiredMultifactorCode; + + +/** + * Called when finished logging in to a WordPress.com site via a Google token. + * + * @param googleIDToken the token used + * @param authToken authToken to be used to access the site + */ +- (void)finishedLoginWithGoogleIDToken:(NSString *)googleIDToken authToken:(NSString *)authToken; + + +/** + * Called when finished logging in to a WordPress.com site via a 2FA Nonce. + * + * @param authToken authToken to be used to access the site + */ +- (void)finishedLoginWithNonceAuthToken:(NSString *)authToken; + + +/** + * Lets the delegate know that a social login attempt found a matching user, but + * their account has not been connected to the social service previously. + * + * @param email The email address that was matched. + */ +- (void)existingUserNeedsConnection:(NSString *)email; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/WordPressAuthenticator/Sources/Services/LoginFacade.m b/WordPressAuthenticator/Sources/Services/LoginFacade.m new file mode 100644 index 000000000000..70be625ca4d1 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/LoginFacade.m @@ -0,0 +1,179 @@ +#import "LoginFacade.h" +#import "NSURL+IDN.h" +#import "WordPressXMLRPCAPIFacade.h" +#import "WPAuthenticator-Swift.h" +@import WordPressKit; + +@implementation LoginFacade + +@synthesize delegate; +@synthesize wordpressComOAuthClientFacade = _wordpressComOAuthClientFacade; +@synthesize wordpressXMLRPCAPIFacade = _wordpressXMLRPCAPIFacade; + +- (instancetype)initWithDotcomClientID:(NSString *)dotcomClientID dotcomSecret:(NSString *)dotcomSecret userAgent:(NSString *)userAgent +{ + self = [super init]; + if (self) { + _wordpressComOAuthClientFacade = [[WordPressComOAuthClientFacade alloc] initWithClient:dotcomClientID secret:dotcomSecret]; + _wordpressXMLRPCAPIFacade = [[WordPressXMLRPCAPIFacade alloc] initWithUserAgent:userAgent]; + } + return self; +} + +- (void)signInWithLoginFields:(LoginFields *)loginFields +{ + NSAssert(self.delegate != nil, @"Must set delegate to use service"); + + if (loginFields.userIsDotCom || loginFields.siteAddress.isWordPressComPath) { + [self signInToWordpressDotCom:loginFields]; + } else { + [self signInToSelfHosted:loginFields]; + } +} + +- (void)loginToWordPressDotComWithSocialIDToken:(NSString *)token + service:(NSString *)service +{ + if ([self.delegate respondsToSelector:@selector(displayLoginMessage:)]) { + [self.delegate displayLoginMessage:NSLocalizedString(@"Connecting to WordPress.com", nil)]; + } + + [self.wordpressComOAuthClientFacade authenticateWithSocialIDToken:token + service:service + success:^(NSString *authToken) { + if ([service isEqualToString:@"google"] && [self.delegate respondsToSelector:@selector(finishedLoginWithGoogleIDToken:authToken:)]) { + // Apple is handled in AppleAuthenticator + [self.delegate finishedLoginWithGoogleIDToken:token authToken:authToken]; + } + [self trackSuccess]; + } needsMultifactor:^(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo){ + if ([self.delegate respondsToSelector:@selector(needsMultifactorCodeForUserID:andNonceInfo:)]) { + [self.delegate needsMultifactorCodeForUserID:userID andNonceInfo:nonceInfo]; + } + } existingUserNeedsConnection: ^(NSString *email) { + // Apple is handled in AppleAuthenticator + if ([self.delegate respondsToSelector:@selector(existingUserNeedsConnection:)]) { + [self.delegate existingUserNeedsConnection: email]; + } + } failure:^(NSError *error) { + [self track:WPAnalyticsStatLoginFailed error:error]; + [self track:WPAnalyticsStatLoginSocialFailure error:error]; + if ([self.delegate respondsToSelector:@selector(displayRemoteError:)]) { + [self.delegate displayRemoteError:error]; + } + }]; +} + +- (void)loginToWordPressDotComWithUser:(NSInteger)userID + authType:(NSString *)authType + twoStepCode:(NSString *)twoStepCode + twoStepNonce:(NSString *)twoStepNonce +{ + if ([self.delegate respondsToSelector:@selector(displayLoginMessage:)]) { + [self.delegate displayLoginMessage:NSLocalizedString(@"Connecting to WordPress.com", nil)]; + } + + [self.wordpressComOAuthClientFacade authenticateWithSocialLoginUser:userID + authType:authType + twoStepCode:twoStepCode + twoStepNonce:twoStepNonce + success:^(NSString *authToken) { + if ([self.delegate respondsToSelector:@selector(finishedLoginWithNonceAuthToken:)]) { + [self.delegate finishedLoginWithNonceAuthToken:authToken]; + } + [self trackSuccess]; + } failure:^(NSError *error) { + [self track:WPAnalyticsStatLoginFailed error:error]; + if ([self.delegate respondsToSelector:@selector(displayRemoteError:)]) { + [self.delegate displayRemoteError:error]; + } + }]; +} + +- (void)signInToWordpressDotCom:(LoginFields *)loginFields +{ + if ([self.delegate respondsToSelector:@selector(displayLoginMessage:)]) { + [self.delegate displayLoginMessage:NSLocalizedString(@"Connecting to WordPress.com", nil)]; + } + + [self.wordpressComOAuthClientFacade authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:^(NSString *authToken) { + if ([self.delegate respondsToSelector:@selector(finishedLoginWithAuthToken:requiredMultifactorCode:)]) { + [self.delegate finishedLoginWithAuthToken:authToken requiredMultifactorCode:loginFields.requiredMultifactor]; + } + [self trackSuccess]; + } needsMultifactor:^(NSInteger userID, SocialLogin2FANonceInfo *nonceInfo) { + if (nonceInfo == nil && [self.delegate respondsToSelector:@selector(needsMultifactorCode)]) { + [self.delegate needsMultifactorCode]; + } else if (nonceInfo != nil && [self.delegate respondsToSelector:@selector(needsMultifactorCodeForUserID:andNonceInfo:)]) { + [self.delegate needsMultifactorCodeForUserID:userID andNonceInfo:nonceInfo]; + } + } failure:^(NSError *error) { + [self track:WPAnalyticsStatLoginFailed error:error]; + if ([self.delegate respondsToSelector:@selector(displayRemoteError:)]) { + [self.delegate displayRemoteError:error]; + } + }]; +} + +- (void)signInToSelfHosted:(LoginFields *)loginFields +{ + void (^guessXMLRPCURLSuccess)(NSURL *) = ^(NSURL *xmlRPCURL) { + loginFields.xmlrpcURL = xmlRPCURL; + [self loginToSelfHosted:loginFields]; + }; + + void (^guessXMLRPCURLFailure)(NSError *) = ^(NSError *error){ + [self track:WPAnalyticsStatLoginFailedToGuessXMLRPC error:error]; + [self track:WPAnalyticsStatLoginFailed error:error]; + [self.delegate displayRemoteError:error]; + }; + + if ([self.delegate respondsToSelector:@selector(displayLoginMessage:)]) { + [self.delegate displayLoginMessage:NSLocalizedString(@"Authenticating", nil)]; + } + + NSString *siteUrl = [NSURL IDNEncodedURL: loginFields.siteAddress]; + [self.wordpressXMLRPCAPIFacade guessXMLRPCURLForSite:siteUrl success:guessXMLRPCURLSuccess failure:guessXMLRPCURLFailure]; +} + +- (void)loginToSelfHosted:(LoginFields *)loginFields +{ + NSURL *xmlRPCURL = loginFields.xmlrpcURL; + [self.wordpressXMLRPCAPIFacade getBlogOptionsWithEndpoint:xmlRPCURL username:loginFields.username password:loginFields.password success:^(id options) { + if ([options objectForKey:@"wordpress.com"] != nil) { + [self signInToWordpressDotCom:loginFields]; + } else { + NSString *versionString = options[@"software_version"][@"value"]; + NSString *minimumSupported = [WordPressOrgXMLRPCApi minimumSupportedVersion]; + CGFloat version = [versionString floatValue]; + + if (version > 0 && version < [minimumSupported floatValue]) { + NSString *errorMessage = [NSString stringWithFormat:NSLocalizedString(@"WordPress version too old. The site at %@ uses WordPress %@. We recommend to update to the latest version, or at least %@", nil), [xmlRPCURL host], versionString, minimumSupported]; + NSError *versionError = [NSError errorWithDomain:WordPressAuthenticator.errorDomain + code:WordPressAuthenticator.invalidVersionErrorCode + userInfo:@{NSLocalizedDescriptionKey:errorMessage}]; + [self track:WPAnalyticsStatLoginFailed error:versionError]; + [self.delegate displayRemoteError:versionError]; + return; + } + NSString *xmlrpc = [xmlRPCURL absoluteString]; + [self.delegate finishedLoginWithUsername:loginFields.username password:loginFields.password xmlrpc:xmlrpc options:options]; + [self trackSuccess]; + } + } failure:^(NSError *error) { + [self track:WPAnalyticsStatLoginFailed error:error]; + [self.delegate displayRemoteError:error]; + }]; +} + +- (void)track:(WPAnalyticsStat)stat +{ + [WordPressAuthenticator track:stat]; +} + +- (void)track:(WPAnalyticsStat)stat error:(NSError *)error +{ + [WordPressAuthenticator track:stat error:error]; +} + +@end diff --git a/WordPressAuthenticator/Sources/Services/LoginFacade.swift b/WordPressAuthenticator/Sources/Services/LoginFacade.swift new file mode 100644 index 000000000000..f968a12e28eb --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/LoginFacade.swift @@ -0,0 +1,110 @@ +import Foundation +import WordPressKit + +/// Extension for handling 2FA authentication. +/// +public extension LoginFacade { + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + func requestOneTimeCode(with loginFields: LoginFields) { + wordpressComOAuthClientFacade.requestOneTimeCode( + username: loginFields.username, + password: loginFields.password, + success: { [weak self] in + guard let self = self else { + return + } + + if self.tracker.shouldUseLegacyTracker() { + WordPressAuthenticator.track(.twoFactorSentSMS) + } + }) { _ in + WPAuthenticatorLogError("Failed to request one time code") + } + } + + func requestSocial2FACode(with loginFields: LoginFields) { + guard let nonce = loginFields.nonceInfo?.nonceSMS else { + return + } + + wordpressComOAuthClientFacade.requestSocial2FACode( + userID: loginFields.nonceUserID, + nonce: nonce, + success: { [weak self] newNonce in + guard let self = self else { + return + } + + loginFields.nonceInfo?.nonceSMS = newNonce + + if self.tracker.shouldUseLegacyTracker() { + WordPressAuthenticator.track(.twoFactorSentSMS) + } + }) { (_, newNonce) in + if let newNonce = newNonce { + loginFields.nonceInfo?.nonceSMS = newNonce + } + WPAuthenticatorLogError("Failed to request one time code") + } + } + + /// Async function that returns the necessary `WebauthnChallengeInfo` to start allow for a security key log in. + /// + func requestWebauthnChallenge(userID: Int, twoStepNonce: String) async -> WebauthnChallengeInfo? { + + delegate?.displayLoginMessage?(NSLocalizedString("Waiting for security key", comment: "Text while waiting for a security key challenge")) + + return await withCheckedContinuation { continuation in + wordpressComOAuthClientFacade.requestWebauthnChallenge(userID: Int64(userID), twoStepNonce: twoStepNonce, success: { challengeInfo in + continuation.resume(returning: challengeInfo) + }, failure: { [weak self] error in + guard let self else { return } + + WPAuthenticatorLogError("Failed to request webauthn challenge \(error)") + WordPressAuthenticator.track(.loginFailed, error: error) + continuation.resume(returning: nil) + + DispatchQueue.main.async { + self.delegate?.displayRemoteError?(error) + } + }) + } + } + + /// Forwards the authentication signature message and updates delegates accordingly. + /// + func authenticateWebauthnSignature(userID: Int, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data) { + + delegate?.displayLoginMessage?(NSLocalizedString("Waiting for security key", comment: "Text while the webauthn signature is being verified")) + + wordpressComOAuthClientFacade.authenticateWebauthnSignature(userID: Int64(userID), + twoStepNonce: twoStepNonce, + credentialID: credentialID, + clientDataJson: clientDataJson, + authenticatorData: authenticatorData, + signature: signature, + userHandle: userHandle, + success: { [weak self] accessToken in + self?.delegate?.finishedLogin?(withNonceAuthToken: accessToken) + self?.trackSuccess() + }, failure: { [weak self] error in + WPAuthenticatorLogError("Failed to verify webauthn signature \(error)") + WordPressAuthenticator.track(.loginFailed, error: error) + self?.delegate?.displayRemoteError?(error) + }) + } + + @objc + func trackSuccess() { + tracker.track(step: .success) + } +} diff --git a/WordPressAuthenticator/Sources/Services/SafariCredentialsService.swift b/WordPressAuthenticator/Sources/Services/SafariCredentialsService.swift new file mode 100644 index 000000000000..a8df9211d8e4 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/SafariCredentialsService.swift @@ -0,0 +1,89 @@ +// MARK: - SafariCredentialsService +// +class SafariCredentialsService { + + @objc static let LoginSharedWebCredentialFQDN: CFString = "wordpress.com" as CFString + typealias SharedWebCredentialsCallback = (_ credentialsFound: Bool, _ username: String?, _ password: String?) -> Void + + /// Update safari stored credentials. + /// + /// - Parameter loginFields: An instance of LoginFields + /// + class func updateSafariCredentialsIfNeeded(with loginFields: LoginFields) { + // Paranioa. Don't try and update credentials for self-hosted. + if !loginFields.meta.userIsDotCom { + return + } + + // If the user changed screen names, don't try and update/create a new shared web credential. + // We'll let Safari handle creating newly saved usernames/passwords. + if loginFields.storedCredentials?.storedUserameHash != loginFields.username.hash { + return + } + + // If the user didn't change the password from previousl filled password no update is needed. + if loginFields.storedCredentials?.storedPasswordHash == loginFields.password.hash { + return + } + + // Update the shared credential + let username: CFString = loginFields.username as CFString + let password: CFString = loginFields.password as CFString + + SecAddSharedWebCredential(LoginSharedWebCredentialFQDN, username, password, { (error: CFError?) in + guard error == nil else { + let err = error + WPAuthenticatorLogError("Error occurred updating shared web credential: \(String(describing: err?.localizedDescription))") + return + } + DispatchQueue.main.async(execute: { + WordPressAuthenticator.track(.loginAutoFillCredentialsUpdated) + }) + }) + } + + /// Request shared safari credentials if they exist. + /// + /// - Parameter completion: A completion block. + /// + class func requestSharedWebCredentials(_ completion: @escaping SharedWebCredentialsCallback) { + SecRequestSharedWebCredential(LoginSharedWebCredentialFQDN, nil, { (credentials: CFArray?, error: CFError?) in + WPAuthenticatorLogInfo("Completed requesting shared web credentials") + guard error == nil else { + let err = error as Error? + if let error = err as NSError?, error.code == -25300 { + // An OSStatus of -25300 is expected when no saved credentails are found. + WPAuthenticatorLogInfo("No shared web credenitals found.") + } else { + WPAuthenticatorLogError("Error requesting shared web credentials: \(String(describing: err?.localizedDescription))") + } + DispatchQueue.main.async { + completion(false, nil, nil) + } + return + } + + guard let credentials = credentials, CFArrayGetCount(credentials) > 0 else { + // Saved credentials exist but were not selected. + DispatchQueue.main.async(execute: { + completion(true, nil, nil) + }) + return + } + + // What a chore! + let unsafeCredentials = CFArrayGetValueAtIndex(credentials, 0) + let credentialsDict = unsafeBitCast(unsafeCredentials, to: CFDictionary.self) + + let unsafeUsername = CFDictionaryGetValue(credentialsDict, Unmanaged.passUnretained(kSecAttrAccount).toOpaque()) + let usernameStr = unsafeBitCast(unsafeUsername, to: CFString.self) as String + + let unsafePassword = CFDictionaryGetValue(credentialsDict, Unmanaged.passUnretained(kSecSharedPassword).toOpaque()) + let passwordStr = unsafeBitCast(unsafePassword, to: CFString.self) as String + + DispatchQueue.main.async { + completion(true, usernameStr, passwordStr) + } + }) + } +} diff --git a/WordPressAuthenticator/Sources/Services/SignupService.swift b/WordPressAuthenticator/Sources/Services/SignupService.swift new file mode 100644 index 000000000000..bbd7c8434d24 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/SignupService.swift @@ -0,0 +1,135 @@ +import Foundation +import WordPressShared +import WordPressKit + +/// SignupService: Responsible for creating a new WPCom user and blog. +/// +class SignupService: SocialUserCreating { + + /// Create a new WPcom account using Google signin token + /// + /// - Parameters: + /// - googleToken: the token from a successful Google login + /// - success: block called when account is created successfully + /// - failure: block called when account creation fails + /// + func createWPComUserWithGoogle(token: String, + success: @escaping (_ newAccount: Bool, _ username: String, _ wpcomToken: String) -> Void, + failure: @escaping (_ error: Error) -> Void) { + + let remote = WordPressComServiceRemote(wordPressComRestApi: anonymousAPI) + + remote.createWPComAccount(withGoogle: token, + andClientID: configuration.wpcomClientId, + andClientSecret: configuration.wpcomSecret, + success: { response in + + guard let username = response?[ResponseKeys.username] as? String, + let bearer_token = response?[ResponseKeys.bearerToken] as? String else { + failure(SignupError.unknown) + return + } + + let createdAccount = (response?[ResponseKeys.createdAccount] as? Int ?? 0) == 1 + success(createdAccount, username, bearer_token) + }, failure: { error in + failure(error ?? SignupError.unknown) + }) + } + + /// Create a new WPcom account using Apple ID + /// + /// - Parameters: + /// - token: Token provided by Apple. + /// - email: Email provided by Apple. + /// - fullName: Formatted full name provided by Apple. + /// - success: Block called when account is created successfully. + /// - failure: Block called when account creation fails. + /// + func createWPComUserWithApple(token: String, + email: String, + fullName: String?, + success: @escaping (_ newAccount: Bool, + _ existingNonSocialAccount: Bool, + _ existing2faAccount: Bool, + _ username: String, + _ wpcomToken: String) -> Void, + failure: @escaping (_ error: Error) -> Void) { + let remote = WordPressComServiceRemote(wordPressComRestApi: anonymousAPI) + + remote.createWPComAccount(withApple: token, + andEmail: email, + andFullName: fullName, + andClientID: configuration.wpcomClientId, + andClientSecret: configuration.wpcomSecret, + success: { response in + guard let username = response?[ResponseKeys.username] as? String, + let bearer_token = response?[ResponseKeys.bearerToken] as? String else { + failure(SignupError.unknown) + return + } + + let createdAccount = (response?[ResponseKeys.createdAccount] as? Int ?? 0) == 1 + success(createdAccount, false, false, username, bearer_token) + }, failure: { error in + if let error = (error as NSError?) { + + if (error.userInfo[ErrorKeys.errorCode] as? String ?? "") == ErrorKeys.twoFactorEnabled { + success(false, true, true, "", "") + return + } + + if (error.userInfo[ErrorKeys.errorCode] as? String ?? "") == ErrorKeys.existingNonSocialUser { + + // If an account already exists, the account email should be returned in the Error response. + // Extract it and return it. + var existingEmail = "" + if let errorData = error.userInfo[WordPressComRestApi.ErrorKeyErrorData] as? [String: String] { + let emailDict = errorData.first { $0.key == WordPressComRestApi.ErrorKeyErrorDataEmail } + let email = emailDict?.value ?? "" + existingEmail = email + } + + success(false, true, false, existingEmail, "") + return + } + } + + failure(error ?? SignupError.unknown) + }) + } + +} + +// MARK: - Private +// +private extension SignupService { + + var anonymousAPI: WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, + userAgent: configuration.userAgent, + baseURL: configuration.wpcomAPIBaseURL) + } + + var configuration: WordPressAuthenticatorConfiguration { + return WordPressAuthenticator.shared.configuration + } + + struct ResponseKeys { + static let bearerToken = "bearer_token" + static let username = "username" + static let createdAccount = "created_account" + } + + struct ErrorKeys { + static let errorCode = "WordPressComRestApiErrorCodeKey" + static let existingNonSocialUser = "user_exists" + static let twoFactorEnabled = "2FA_enabled" + } +} + +// MARK: - Errors +// +enum SignupError: Error { + case unknown +} diff --git a/WordPressAuthenticator/Sources/Services/SocialUser.swift b/WordPressAuthenticator/Sources/Services/SocialUser.swift new file mode 100644 index 000000000000..372287ddde87 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/SocialUser.swift @@ -0,0 +1,8 @@ +import WordPressKit + +public struct SocialUser { + + public let email: String + public let fullName: String + public let service: SocialServiceName +} diff --git a/WordPressAuthenticator/Sources/Services/SocialUserCreating.swift b/WordPressAuthenticator/Sources/Services/SocialUserCreating.swift new file mode 100644 index 000000000000..63b1b6942460 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/SocialUserCreating.swift @@ -0,0 +1,23 @@ +/// A type that can create WordPress.com users given a social users, either coming from Google or Apple. +protocol SocialUserCreating: AnyObject { + + func createWPComUserWithGoogle( + token: String, + success: @escaping (_ newAccount: Bool, _ username: String, _ wpcomToken: String) -> Void, + failure: @escaping (_ error: Error) -> Void + ) + + func createWPComUserWithApple( + token: String, + email: String, + fullName: String?, + success: @escaping ( + _ newAccount: Bool, + _ existingNonSocialAccount: Bool, + _ existing2faAccount: Bool, + _ username: String, + _ wpcomToken: String + ) -> Void, + failure: @escaping (_ error: Error) -> Void + ) +} diff --git a/WordPressAuthenticator/Sources/Services/WordPressComAccountService.swift b/WordPressAuthenticator/Sources/Services/WordPressComAccountService.swift new file mode 100644 index 000000000000..ac348b557346 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressComAccountService.swift @@ -0,0 +1,103 @@ +import Foundation +import WordPressKit + +// MARK: - WordPressComAccountService +// +public class WordPressComAccountService { + + /// Makes the intializer public for external access. + public init() {} + + /// Indicates if a WordPress.com account is "PasswordLess": This kind of account must be authenticated via a Magic Link. + /// + public func isPasswordlessAccount(username: String, success: @escaping (Bool) -> Void, failure: @escaping (Error) -> Void) { + let remote = AccountServiceRemoteREST(wordPressComRestApi: anonymousAPI) + + remote.isPasswordlessAccount(username, success: { isPasswordless in + success(isPasswordless) + }, failure: { error in + let result = error ?? ServiceError.unknown + failure(result) + }) + } + + /// Connects a WordPress.com account with the specified Social Service. + /// + func connect(wpcomAuthToken: String, + serviceName: SocialServiceName, + serviceToken: String, + connectParameters: [String: AnyObject]? = nil, + success: @escaping () -> Void, + failure: @escaping (Error) -> Void) { + let loggedAPI = WordPressComRestApi(oAuthToken: wpcomAuthToken, + userAgent: configuration.userAgent, + baseURL: configuration.wpcomAPIBaseURL) + let remote = AccountServiceRemoteREST(wordPressComRestApi: loggedAPI) + + remote.connectToSocialService(serviceName, + serviceIDToken: serviceToken, + connectParameters: connectParameters, + oAuthClientID: configuration.wpcomClientId, + oAuthClientSecret: configuration.wpcomSecret, + success: success, + failure: { error in + failure(error) + }) + } + + /// Requests a WordPress.com Authentication Link to be sent to the specified email address. + /// + public func requestAuthenticationLink(for email: String, jetpackLogin: Bool, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let remote = AccountServiceRemoteREST(wordPressComRestApi: anonymousAPI) + + remote.requestWPComAuthLink(forEmail: email, + clientID: configuration.wpcomClientId, + clientSecret: configuration.wpcomSecret, + source: jetpackLogin ? .jetpackConnect : .default, + wpcomScheme: configuration.wpcomScheme, + success: success, + failure: { error in + let result = error ?? ServiceError.unknown + failure(result) + }) + } + + /// Requests a WordPress.com SignUp Link to be sent to the specified email address. + /// + func requestSignupLink(for email: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { + let remote = AccountServiceRemoteREST(wordPressComRestApi: anonymousAPI) + + remote.requestWPComSignupLink(forEmail: email, + clientID: configuration.wpcomClientId, + clientSecret: configuration.wpcomSecret, + wpcomScheme: configuration.wpcomScheme, + success: success, + failure: { error in + let result = error ?? ServiceError.unknown + failure(result) + }) + } + + /// Returns an anonymous WordPressComRestApi Instance. + /// + private var anonymousAPI: WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, + userAgent: configuration.userAgent, + baseURL: configuration.wpcomAPIBaseURL) + } + + /// Returns the current WordPressAuthenticatorConfiguration Instance. + /// + private var configuration: WordPressAuthenticatorConfiguration { + return WordPressAuthenticator.shared.configuration + } +} + +// MARK: - Nested Types +// +extension WordPressComAccountService { + + enum ServiceError: Error { + case unknown + } +} diff --git a/WordPressAuthenticator/Sources/Services/WordPressComBlogService.swift b/WordPressAuthenticator/Sources/Services/WordPressComBlogService.swift new file mode 100644 index 000000000000..0d83f9eb3216 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressComBlogService.swift @@ -0,0 +1,68 @@ +import Foundation +import WordPressKit + +// MARK: - WordPress.com BlogService +// +class WordPressComBlogService { + + /// Returns a new anonymous instance of WordPressComRestApi. + /// + private var anonymousAPI: WordPressComRestApi { + let userAgent = WordPressAuthenticator.shared.configuration.userAgent + let baseUrl = WordPressAuthenticator.shared.configuration.wpcomAPIBaseURL + return WordPressComRestApi(oAuthToken: nil, userAgent: userAgent, baseURL: baseUrl) + } + + /// Retrieves the WordPressComSiteInfo instance associated to a WordPress.com Site Address. + /// + func fetchSiteInfo(for address: String, success: @escaping (WordPressComSiteInfo) -> Void, failure: @escaping (Error) -> Void) { + let remote = BlogServiceRemoteREST(wordPressComRestApi: anonymousAPI, siteID: 0) + + remote.fetchSiteInfo(forAddress: address, success: { response in + guard let response = response else { + failure(ServiceError.unknown) + return + } + + let site = WordPressComSiteInfo(remote: response) + success(site) + + }, failure: { error in + let result = error ?? ServiceError.unknown + failure(result) + }) + } + + func fetchUnauthenticatedSiteInfoForAddress(for address: String, success: @escaping (WordPressComSiteInfo) -> Void, failure: @escaping (Error) -> Void) { + let remote = BlogServiceRemoteREST(wordPressComRestApi: anonymousAPI, siteID: 0) + remote.fetchUnauthenticatedSiteInfo(forAddress: address, success: { response in + guard let response = response else { + failure(ServiceError.unknown) + return + } + + let site = WordPressComSiteInfo(remote: response) + guard site.url != Constants.wordPressBlogURL else { + failure(ServiceError.invalidWordPressAddress) + return + } + success(site) + }, failure: { error in + let result = error ?? ServiceError.unknown + failure(result) + }) + } +} + +// MARK: - Nested Types +// +extension WordPressComBlogService { + enum Constants { + static let wordPressBlogURL = "https://wordpress.com/blog" + } + + enum ServiceError: Error { + case unknown + case invalidWordPressAddress + } +} diff --git a/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacade.swift b/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacade.swift new file mode 100644 index 000000000000..a6a3d7345bef --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacade.swift @@ -0,0 +1,121 @@ +import Foundation +import WordPressKit + +@objc public class WordPressComOAuthClientFacade: NSObject, WordPressComOAuthClientFacadeProtocol { + + private let client: WordPressComOAuthClient + + @objc required public init(client: String, secret: String) { + self.client = WordPressComOAuthClient( + clientID: client, + secret: secret, + wordPressComBaseURL: WordPressAuthenticator.shared.configuration.wpcomBaseURL, + wordPressComApiBaseURL: WordPressAuthenticator.shared.configuration.wpcomAPIBaseURL + ) + } + + public func authenticate( + username: String, + password: String, + multifactorCode: String?, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo?) -> Void), + failure: ((_ error: Error) -> Void)? + ) { + self.client.authenticate(username: username, password: password, multifactorCode: multifactorCode, needsMultifactor: needsMultifactor, success: success, failure: { error in + if case let .endpointError(authenticationFailure) = error, authenticationFailure.kind == .needsMultifactorCode { + needsMultifactor(0, nil) + } else { + failure?(error) + } + }) + } + + public func requestOneTimeCode( + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (_ error: Error) -> Void + ) { + self.client.requestOneTimeCode(username: username, password: password, success: success, failure: failure) + } + + public func requestSocial2FACode( + userID: Int, + nonce: String, + success: @escaping (_ newNonce: String) -> Void, + failure: @escaping (_ error: Error, _ newNonce: String?) -> Void + ) { + self.client.requestSocial2FACode(userID: userID, nonce: nonce, success: success, failure: failure) + } + + public func authenticate( + socialIDToken: String, + service: String, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + existingUserNeedsConnection: @escaping (_ email: String) -> Void, + failure: @escaping (_ error: Error) -> Void + ) { + self.client.authenticate( + socialIDToken: socialIDToken, + service: service, + success: success, + needsMultifactor: needsMultifactor, + existingUserNeedsConnection: existingUserNeedsConnection, + failure: failure + ) + } + + public func authenticate( + socialLoginUser userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String, + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: Error) -> Void + ) { + self.client.authenticate( + socialLoginUserID: userID, + authType: authType, + twoStepCode: twoStepCode, + twoStepNonce: twoStepNonce, + success: success, + failure: failure + ) + } + + public func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String, + success: @escaping (_ challengeData: WebauthnChallengeInfo) -> Void, + failure: @escaping (_ error: Error) -> Void + ) { + self.client.requestWebauthnChallenge(userID: userID, twoStepNonce: twoStepNonce, success: success, failure: failure) + } + + public func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data, + success: @escaping (_ authToken: String) -> Void, + failure: @escaping (_ error: Error) -> Void + ) { + self.client.authenticateWebauthnSignature( + userID: userID, + twoStepNonce: twoStepNonce, + credentialID: credentialID, + clientDataJson: clientDataJson, + authenticatorData: authenticatorData, + signature: signature, + userHandle: userHandle, + success: success, + failure: failure + ) + } + +} diff --git a/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacadeProtocol.swift b/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacadeProtocol.swift new file mode 100644 index 000000000000..a455ca633a00 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressComOAuthClientFacadeProtocol.swift @@ -0,0 +1,69 @@ +import Foundation + +import WordPressKit + +@objc public protocol WordPressComOAuthClientFacadeProtocol { + + init(client: String, secret: String) + + func authenticate( + username: String, + password: String, + multifactorCode: String?, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo?) -> Void), + failure: ((_ error: Error) -> Void)? + ) + + func requestOneTimeCode( + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (_ error: Error) -> Void + ) + + func requestSocial2FACode( + userID: Int, + nonce: String, + success: @escaping (_ newNonce: String) -> Void, + failure: @escaping (_ error: Error, _ newNonce: String?) -> Void + ) + + func authenticate( + socialIDToken: String, + service: String, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + existingUserNeedsConnection: @escaping (_ email: String) -> Void, + failure: @escaping (_ error: Error) -> Void + ) + + func authenticate( + socialLoginUser userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String, + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: Error) -> Void + ) + + func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String, + success: @escaping (_ challengeData: WebauthnChallengeInfo) -> Void, + failure: @escaping (_ error: Error) -> Void + ) + + func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data, + success: @escaping (_ authToken: String) -> Void, + failure: @escaping (_ error: Error) -> Void + ) + +} diff --git a/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.h b/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.h new file mode 100644 index 000000000000..f1689a3c2280 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.h @@ -0,0 +1,23 @@ +#import + +@protocol WordPressXMLRPCAPIFacade + +extern NSString *const XMLRPCOriginalErrorKey; + +- (instancetype)initWithUserAgent:(NSString *)userAgent; + +- (void)guessXMLRPCURLForSite:(NSString *)url + success:(void (^)(NSURL *xmlrpcURL))success + failure:(void (^)(NSError *error))failure; + +- (void)getBlogOptionsWithEndpoint:(NSURL *)xmlrpc + username:(NSString *)username + password:(NSString *)password + success:(void (^)(NSDictionary *options))success + failure:(void (^)(NSError *error))failure; + +@end + +@interface WordPressXMLRPCAPIFacade : NSObject + +@end diff --git a/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.m b/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.m new file mode 100644 index 000000000000..77b7d349c136 --- /dev/null +++ b/WordPressAuthenticator/Sources/Services/WordPressXMLRPCAPIFacade.m @@ -0,0 +1,99 @@ +#import "WordPressXMLRPCAPIFacade.h" +#import +#import "WPAuthenticator-Swift.h" + +@import WordPressKit; + + + +@interface WordPressXMLRPCAPIFacade () + +@property (nonatomic, strong) NSString *userAgent; + +@end + +NSString *const XMLRPCOriginalErrorKey = @"XMLRPCOriginalErrorKey"; + +@implementation WordPressXMLRPCAPIFacade + +- (instancetype)initWithUserAgent:(NSString *)userAgent +{ + self = [super init]; + if (self) { + _userAgent = userAgent; + } + + return self; +} + +- (void)guessXMLRPCURLForSite:(NSString *)url + success:(void (^)(NSURL *xmlrpcURL))success + failure:(void (^)(NSError *error))failure +{ + WordPressOrgXMLRPCValidator *validator = [[WordPressOrgXMLRPCValidator alloc] init]; + [validator guessXMLRPCURLForSite:url + userAgent:self.userAgent + success:success + failure:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + failure([self errorForGuessXMLRPCApiFailure:error]); + }); + }]; +} + +- (NSError *)errorForGuessXMLRPCApiFailure:(NSError *)error +{ + WPAuthenticatorLogError(@"Error on trying to guess XMLRPC site: %@", error); + NSArray *errorCodes = @[ + @(NSURLErrorUserCancelledAuthentication), + @(NSURLErrorNotConnectedToInternet), + @(NSURLErrorNetworkConnectionLost), + ]; + if ([error.domain isEqual:NSURLErrorDomain] && [errorCodes containsObject:@(error.code)]) { + return error; + } else { + NSDictionary *userInfo = @{ + NSLocalizedDescriptionKey: NSLocalizedString(@"Unable to read the WordPress site at that URL. Tap 'Need more help?' to view the FAQ.", nil), + NSLocalizedFailureReasonErrorKey: error.localizedDescription, + XMLRPCOriginalErrorKey: error + }; + + NSError *err = [NSError errorWithDomain:WordPressAuthenticator.errorDomain code:NSURLErrorBadURL userInfo:userInfo]; + return err; + } +} + +- (void)getBlogOptionsWithEndpoint:(NSURL *)xmlrpc + username:(NSString *)username + password:(NSString *)password + success:(void (^)(NSDictionary *options))success + failure:(void (^)(NSError *error))failure; +{ + + WordPressOrgXMLRPCApi *api = [[WordPressOrgXMLRPCApi alloc] initWithEndpoint:xmlrpc userAgent:self.userAgent]; + [api checkCredentials:username password:password success:^(id responseObject, NSHTTPURLResponse *httpResponse) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (![responseObject isKindOfClass:[NSDictionary class]]) { + if (failure) { + NSDictionary *userInfo = @{NSLocalizedDescriptionKey: NSLocalizedString(@"Unable to read the WordPress site at that URL. Tap 'Need more help?' to view the FAQ.", nil)}; + NSError *error = [NSError errorWithDomain:WordPressOrgXMLRPCApiErrorDomain code:WordPressOrgXMLRPCApiErrorResponseSerializationFailed userInfo:userInfo]; + failure(error); + } + return; + } + if (success) { + success((NSDictionary *)responseObject); + } + }); + + } failure:^(NSError *error, NSHTTPURLResponse *httpResponse) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (failure) { + failure(error); + } + }); + }]; +} + + +@end diff --git a/WordPressAuthenticator/Sources/Signin/AppleAuthenticator.swift b/WordPressAuthenticator/Sources/Signin/AppleAuthenticator.swift new file mode 100644 index 000000000000..8b8512b78299 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/AppleAuthenticator.swift @@ -0,0 +1,292 @@ +import Foundation +import AuthenticationServices +import WordPressKit +import SVProgressHUD + +@objc protocol AppleAuthenticatorDelegate { + func showWPComLogin(loginFields: LoginFields) + func showApple2FA(loginFields: LoginFields) + func authFailedWithError(message: String) +} + +class AppleAuthenticator: NSObject { + + // MARK: - Properties + + static var sharedInstance: AppleAuthenticator = AppleAuthenticator() + private var showFromViewController: UIViewController? + private let loginFields = LoginFields() + weak var delegate: AppleAuthenticatorDelegate? + let signupService: SocialUserCreating + + init(signupService: SocialUserCreating = SignupService()) { + self.signupService = signupService + super.init() + } + + static let credentialRevokedNotification = ASAuthorizationAppleIDProvider.credentialRevokedNotification + + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + private var authenticationDelegate: WordPressAuthenticatorDelegate { + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError() + } + return delegate + } + + // MARK: - Start Authentication + + func showFrom(viewController: UIViewController) { + loginFields.meta.socialService = SocialServiceName.apple + showFromViewController = viewController + requestAuthorization() + } +} + +// MARK: - Tracking + +private extension AppleAuthenticator { + func track(_ event: WPAnalyticsStat, properties: [AnyHashable: Any] = [:]) { + var trackProperties = properties + trackProperties["source"] = "apple" + WordPressAuthenticator.track(event, properties: trackProperties) + } +} + +// MARK: - Authentication Flow + +private extension AppleAuthenticator { + + func requestAuthorization() { + let provider = ASAuthorizationAppleIDProvider() + let request = provider.createRequest() + request.requestedScopes = [.fullName, .email] + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + + controller.presentationContextProvider = self + controller.performRequests() + } + + /// Creates a WordPress.com account with the Apple ID + /// + func createWordPressComUser(appleCredentials: ASAuthorizationAppleIDCredential) { + guard let identityToken = appleCredentials.identityToken, + let token = String(data: identityToken, encoding: .utf8) else { + WPAuthenticatorLogError("Apple Authenticator: invalid Apple credentials.") + return + } + + createWordPressComUser( + appleUserId: appleCredentials.user, + email: appleCredentials.email ?? "", + name: fullName(from: appleCredentials.fullName), + token: token + ) + } + + func signupSuccessful(with credentials: AuthenticatorCredentials) { + // This stat is part of a funnel that provides critical information. Before + // making ANY modification to this stat please refer to: p4qSXL-35X-p2 + track(.createdAccount) + + tracker.track(step: .success) { + track(.signupSocialSuccess) + } + + showSignupEpilogue(for: credentials) + } + + func loginSuccessful(with credentials: AuthenticatorCredentials) { + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + track(.signedIn) + + tracker.track(step: .success) { + track(.loginSocialSuccess) + } + + showLoginEpilogue(for: credentials) + } + + func showLoginEpilogue(for credentials: AuthenticatorCredentials) { + guard let navigationController = showFromViewController?.navigationController else { + fatalError() + } + + authenticationDelegate.presentLoginEpilogue(in: navigationController, + for: credentials, + source: WordPressAuthenticator.shared.signInSource) {} + } + + func signupFailed(with error: Error) { + WPAuthenticatorLogError("Apple Authenticator: Signup failed. error: \(error.localizedDescription)") + + let errorMessage = error.localizedDescription + + tracker.track(failure: errorMessage) { + let properties = ["error": errorMessage] + track(.signupSocialFailure, properties: properties) + } + + delegate?.authFailedWithError(message: error.localizedDescription) + } + + func logInInstead() { + tracker.set(flow: .loginWithApple) + tracker.track(step: .start) { + track(.signupSocialToLogin) + track(.loginSocialSuccess) + } + + delegate?.showWPComLogin(loginFields: loginFields) + } + + func show2FA() { + if tracker.shouldUseLegacyTracker() { + track(.signupSocialToLogin) + } + + delegate?.showApple2FA(loginFields: loginFields) + } + + // MARK: - Helpers + + func fullName(from components: PersonNameComponents?) -> String { + guard let name = components else { + return "" + } + return PersonNameComponentsFormatter().string(from: name) + } + + func updateLoginFields(email: String, fullName: String, token: String) { + updateLoginEmail(email) + loginFields.meta.socialServiceIDToken = token + loginFields.meta.socialUser = SocialUser(email: email, fullName: fullName, service: .apple) + } + + func updateLoginEmail(_ email: String) { + loginFields.emailAddress = email + loginFields.username = email + } + +} + +extension AppleAuthenticator: ASAuthorizationControllerDelegate { + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + switch authorization.credential { + case let credentials as ASAuthorizationAppleIDCredential: + createWordPressComUser(appleCredentials: credentials) + default: + break + } + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + + // Don't show error if user cancelled authentication. + if let authorizationError = error as? ASAuthorizationError, + authorizationError.code == .canceled { + return + } + + WPAuthenticatorLogError("Apple Authenticator: didCompleteWithError: \(error.localizedDescription)") + let message = NSLocalizedString("Apple authentication failed.\nPlease make sure you are signed in to iCloud with an Apple ID that uses two-factor authentication.", comment: "Message shown when Apple authentication fails.") + delegate?.authFailedWithError(message: message) + } +} + +extension AppleAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return showFromViewController?.view.window ?? UIWindow() + } +} + +extension AppleAuthenticator { + func getAppleIDCredentialState(for userID: String, + completion: @escaping (ASAuthorizationAppleIDProvider.CredentialState, Error?) -> Void) { + ASAuthorizationAppleIDProvider().getCredentialState(forUserID: userID, completion: completion) + } +} + +// This needs to be internal, at this point in time, to allow testing. +// +// Notice that none of this code was previously tested. A small encapsulation breach like this is +// worth the testability we gain from it. +extension AppleAuthenticator { + + func showSignupEpilogue(for credentials: AuthenticatorCredentials) { + guard let navigationController = showFromViewController?.navigationController else { + fatalError() + } + + authenticationDelegate.presentSignupEpilogue( + in: navigationController, + for: credentials, + socialUser: loginFields.meta.socialUser + ) + } + + func createWordPressComUser(appleUserId: String, email: String, name: String, token: String) { + tracker.set(flow: .signupWithApple) + tracker.track(step: .start) { + track(.createAccountInitiated) + } + + SVProgressHUD.show( + withStatus: NSLocalizedString( + "Continuing with Apple", + comment: "Shown while logging in with Apple and the app waits for the site creation process to complete." + ) + ) + + updateLoginFields(email: email, fullName: name, token: token) + + signupService.createWPComUserWithApple( + token: token, + email: email, + fullName: name, + success: { [weak self] accountCreated, existingNonSocialAccount, existing2faAccount, wpcomUsername, wpcomToken in + SVProgressHUD.dismiss() + + // Notify host app of successful Apple authentication + self?.authenticationDelegate.userAuthenticatedWithAppleUserID(appleUserId) + + guard !existingNonSocialAccount else { + self?.tracker.set(flow: .loginWithApple) + + if existing2faAccount { + self?.show2FA() + return + } + + self?.updateLoginEmail(wpcomUsername) + self?.logInInstead() + return + } + + let wpcom = WordPressComCredentials(authToken: wpcomToken, isJetpackLogin: false, multifactor: false, siteURL: self?.loginFields.siteAddress ?? "") + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + if accountCreated { + self?.authenticationDelegate.createdWordPressComAccount(username: wpcomUsername, authToken: wpcomToken) + self?.signupSuccessful(with: credentials) + } else { + self?.authenticationDelegate.sync(credentials: credentials) { + self?.loginSuccessful(with: credentials) + } + } + + }, + failure: { [weak self] error in + SVProgressHUD.dismiss() + self?.signupFailed(with: error) + } + ) + } +} diff --git a/WordPressAuthenticator/Sources/Signin/EmailMagicLink.storyboard b/WordPressAuthenticator/Sources/Signin/EmailMagicLink.storyboard new file mode 100644 index 000000000000..505e6e7f0b0d --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/EmailMagicLink.storyboard @@ -0,0 +1,165 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Signin/Login.storyboard b/WordPressAuthenticator/Sources/Signin/Login.storyboard new file mode 100644 index 000000000000..0b039b2f923b --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/Login.storyboard @@ -0,0 +1,1481 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Signin/Login2FAViewController.swift b/WordPressAuthenticator/Sources/Signin/Login2FAViewController.swift new file mode 100644 index 000000000000..495fcf197205 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/Login2FAViewController.swift @@ -0,0 +1,333 @@ +import UIKit +import SVProgressHUD +import WordPressShared +import WordPressKit + +/// Provides a form and functionality for entering a two factor auth code and +/// signing into WordPress.com +/// +class Login2FAViewController: LoginViewController, NUXKeyboardResponder, UITextFieldDelegate { + + @IBOutlet weak var verificationCodeField: LoginTextField! + @IBOutlet weak var sendCodeButton: UIButton! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet var verticalCenterConstraint: NSLayoutConstraint? + + private var pasteboardChangeCountBeforeBackground: Int = 0 + override var sourceTag: WordPressSupportSourceTag { + get { + return .login2FA + } + } + + private enum Constants { + static let headsUpDismissDelay = TimeInterval(1) + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + localizeControls() + configureTextFields() + configureSubmitButton(animating: false) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + configureViewForEditingIfNeeded() + styleSendCodeButton() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(applicationBecameInactive), name: UIApplication.willResignActiveNotification, object: nil) + nc.addObserver(self, selector: #selector(applicationBecameActive), name: UIApplication.didBecomeActiveNotification, object: nil) + + WordPressAuthenticator.track(.loginTwoFactorFormViewed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + NotificationCenter.default.removeObserver(self) + + // Multifactor codes are time sensitive, so clear the stored code if the + // user dismisses the view. They'll need to reentered it upon return. + loginFields.multifactorCode = "" + verificationCodeField.text = "" + } + + // MARK: Dynamic Type + override func didChangePreferredContentSize() { + super.didChangePreferredContentSize() + styleSendCodeButton() + } + + private func styleSendCodeButton() { + sendCodeButton.titleLabel?.adjustsFontForContentSizeCategory = true + sendCodeButton.titleLabel?.adjustsFontSizeToFitWidth = true + WPStyleGuide.configureTextButton(sendCodeButton) + } + + // MARK: Configuration Methods + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + instructionLabel?.text = NSLocalizedString("Almost there! Please enter the verification code from your authenticator app.", comment: "Instructions for users with two-factor authentication enabled.") + + verificationCodeField.placeholder = NSLocalizedString("Verification code", comment: "two factor code placeholder") + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + + sendCodeButton.setTitle(NSLocalizedString("Text me a code instead", comment: "Button title"), + for: .normal) + sendCodeButton.titleLabel?.numberOfLines = 0 + } + + /// configures the text fields + /// + @objc func configureTextFields() { + verificationCodeField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + verificationCodeField.textContentType = .oneTimeCode + + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + let isNumeric = loginFields.multifactorCode.rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil + let isValidLength = SocialLogin2FANonceInfo.TwoFactorTypeLengths(rawValue: loginFields.multifactorCode.count) != nil + + submitButton?.isEnabled = ( + !animating && + isNumeric && + isValidLength + ) + } + + /// Configure the view's loading state. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + verificationCodeField.enablesReturnKeyAutomatically = !loading + + configureSubmitButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editiing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + verificationCodeField.becomeFirstResponder() + } + } + + // MARK: - Instance Methods + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + if let nonce = loginFields.nonceInfo { + loginWithNonce(info: nonce) + return + } + validateFormAndLogin() + } + + private func loginWithNonce(info nonceInfo: SocialLogin2FANonceInfo) { + let code = loginFields.multifactorCode + let (authType, nonce) = nonceInfo.authTypeAndNonce(for: code) + loginFacade.loginToWordPressDotCom(withUser: loginFields.nonceUserID, authType: authType, twoStepCode: code, twoStepNonce: nonce) + } + + func finishedLogin(withNonceAuthToken authToken: String) { + let wpcom = WordPressComCredentials(authToken: authToken, isJetpackLogin: isJetpackLogin, multifactor: true, siteURL: loginFields.siteAddress) + let credentials = AuthenticatorCredentials(wpcom: wpcom) + syncWPComAndPresentEpilogue(credentials: credentials) + + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + WordPressAuthenticator.track(.signedIn) + + var properties = [AnyHashable: Any]() + if let service = loginFields.meta.socialService?.rawValue { + properties["source"] = service + } + + WordPressAuthenticator.track(.loginSocialSuccess, properties: properties) + } + + /// Only allow digits in the 2FA text field + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString: String) -> Bool { + guard let fieldText = textField.text as NSString? else { + return true + } + let resultString = fieldText.replacingCharacters(in: range, with: replacementString) + + switch isValidCode(code: resultString) { + case .valid(let cleanedCode): + displayError(message: "") + + // because the string was stripped of whitespace, we can't return true and we update the textfield ourselves + textField.text = cleanedCode + handleTextFieldDidChange(textField) + case .invalid(nonNumbers: true): + displayError(message: NSLocalizedString("A verification code will only contain numbers.", comment: "Shown when a user types a non-number into the two factor field.")) + default: + if let pasteString = UIPasteboard.general.string, pasteString == replacementString { + displayError(message: NSLocalizedString("That doesn't appear to be a valid verification code.", comment: "Shown when a user pastes a code into the two factor field that contains letters or is the wrong length")) + } + } + + return false + } + + private enum CodeValidation { + case invalid(nonNumbers: Bool) + case valid(String) + } + + private func isValidCode(code: String) -> CodeValidation { + let codeStripped = code.components(separatedBy: .whitespacesAndNewlines).joined() + let allowedCharacters = CharacterSet.decimalDigits + let resultCharacterSet = CharacterSet(charactersIn: codeStripped) + let isOnlyNumbers = allowedCharacters.isSuperset(of: resultCharacterSet) + let isShortEnough = codeStripped.count <= SocialLogin2FANonceInfo.TwoFactorTypeLengths.backup.rawValue + + if isOnlyNumbers && isShortEnough { + return .valid(codeStripped) + } else if isOnlyNumbers { + return .invalid(nonNumbers: false) + } else { + return .invalid(nonNumbers: true) + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + validateForm() + return false + } + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + loginFields.multifactorCode = verificationCodeField.nonNilTrimmedText() + configureSubmitButton(animating: false) + } + + // MARK: - Actions + + @IBAction func handleSubmitForm() { + validateForm() + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + tracker.track(click: .submit) + + validateForm() + } + + @IBAction func handleSendVerificationButtonTapped(_ sender: UIButton) { + self.tracker.track(click: .sendCodeWithText) + + let message = NSLocalizedString("SMS Sent", comment: "One Time Code has been sent via SMS") + SVProgressHUD.showSuccess(withStatus: message) + SVProgressHUD.dismiss(withDelay: Constants.headsUpDismissDelay) + + if let _ = loginFields.nonceInfo { + // social login + loginFacade.requestSocial2FACode(with: loginFields) + } else { + loginFacade.requestOneTimeCode(with: loginFields) + } + } + + // MARK: - Handle application state changes. + + @objc func applicationBecameInactive() { + pasteboardChangeCountBeforeBackground = UIPasteboard.general.changeCount + } + + @objc func applicationBecameActive() { + let emptyField = verificationCodeField.text?.isEmpty ?? true + guard emptyField, + pasteboardChangeCountBeforeBackground != UIPasteboard.general.changeCount else { + return + } + + if #available(iOS 14.0, *) { + UIPasteboard.general.detectAuthenticatorCode { [weak self] result in + switch result { + case .success(let authenticatorCode): + self?.handle(code: authenticatorCode) + case .failure: + break + } + } + } else { + if let pasteString = UIPasteboard.general.string { + handle(code: pasteString) + } + } + } + + private func handle(code: String) { + switch isValidCode(code: code) { + case .valid(let cleanedCode): + displayError(message: "") + verificationCodeField.text = cleanedCode + handleTextFieldDidChange(verificationCodeField) + default: + break + } + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +extension Login2FAViewController { + + override func displayRemoteError(_ error: Error) { + displayError(message: "") + + configureViewLoading(false) + let bad2FAMessage = NSLocalizedString("Whoops, that's not a valid two-factor verification code. Double-check your code and try again!", comment: "Error message shown when an incorrect two factor code is provided.") + if (error as? WordPressComOAuthError)?.authenticationFailureKind == .invalidOneTimePassword { + // Invalid verification code. + displayError(message: bad2FAMessage) + } else if case let .endpointError(authenticationFailure) = (error as? WordPressComOAuthError), authenticationFailure.kind == .invalidTwoStepCode { + // Invalid 2FA during social login + if let newNonce = authenticationFailure.newNonce { + loginFields.nonceInfo?.updateNonce(with: newNonce) + } + displayError(message: bad2FAMessage) + } else { + displayError(error, sourceTag: sourceTag) + } + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginEmailViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginEmailViewController.swift new file mode 100644 index 000000000000..8ad935adffc8 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginEmailViewController.swift @@ -0,0 +1,634 @@ +import UIKit +import WordPressShared +import WordPressKit + +/// This is the first screen following the log in prologue screen if the user chooses to log in. +/// +open class LoginEmailViewController: LoginViewController, NUXKeyboardResponder { + @IBOutlet var emailTextField: WPWalkthroughTextField! + @IBOutlet open var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet open var verticalCenterConstraint: NSLayoutConstraint? + @IBOutlet var inputStack: UIStackView? + @IBOutlet var alternativeLoginLabel: UILabel? + @IBOutlet var hiddenPasswordField: WPWalkthroughTextField? + + var googleLoginButton: UIButton? + var selfHostedLoginButton: UIButton? + + // This signup button isn't for the main flow; it's only shown during Jetpack installation + var wpcomSignupButton: UIButton? + + override open var sourceTag: WordPressSupportSourceTag { + get { + return .loginEmail + } + } + + var didFindSafariSharedCredentials = false + var didRequestSafariSharedCredentials = false + open var offerSignupOption = false + private let showLoginOptions = WordPressAuthenticator.shared.configuration.showLoginOptions + + private struct Constants { + static let alternativeLogInAnimationDuration: TimeInterval = 0.33 + static let keyboardThreshold: CGFloat = 100.0 + } + + // MARK: Lifecycle Methods + + override open func viewDidLoad() { + super.viewDidLoad() + + localizeControls() + + alternativeLoginLabel?.isHidden = showLoginOptions + if !showLoginOptions { + addGoogleButton() + } + + addSelfHostedLogInButton() + addSignupButton() + } + + override open func didChangePreferredContentSize() { + super.didChangePreferredContentSize() + configureEmailField() + configureAlternativeLabel() + } + + override open func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // The old create account vc hides the nav bar, so make sure its always visible. + navigationController?.setNavigationBarHidden(false, animated: false) + + // Update special case login fields. + loginFields.meta.userIsDotCom = true + + configureEmailField() + configureAlternativeLabel() + configureSubmitButton() + configureViewForEditingIfNeeded() + configureForWPComOnlyIfNeeded() + } + + override open func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + WordPressAuthenticator.track(.loginEmailFormViewed) + + hiddenPasswordField?.text = nil + errorToPresent = nil + } + + override open func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + /// Displays the self-hosted login form. + /// + override func loginToSelfHostedSite() { + guard let vc = LoginSiteAddressViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginSiteAddressViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + // MARK: - Setup and Configuration + + /// Hides the self-hosted login option. + /// + func configureForWPComOnlyIfNeeded() { + wpcomSignupButton?.isHidden = !offerSignupOption + selfHostedLoginButton?.isHidden = loginFields.restrictToWPCom + } + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + func localizeControls() { + if loginFields.meta.jetpackLogin { + instructionLabel?.text = WordPressAuthenticator.shared.displayStrings.jetpackLoginInstructions + } else { + instructionLabel?.text = WordPressAuthenticator.shared.displayStrings.emailLoginInstructions + } + emailTextField.placeholder = NSLocalizedString("Email address", comment: "Placeholder for a textfield. The user may enter their email address.") + emailTextField.accessibilityIdentifier = "Login Email Address" + + alternativeLoginLabel?.text = NSLocalizedString("Alternatively:", comment: "String displayed before offering alternative login methods") + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + submitButton?.accessibilityIdentifier = "Login Email Next Button" + } + + /// Add the log in with Google button to the view + /// + func addGoogleButton() { + guard let instructionLabel = instructionLabel, + let stackView = inputStack else { + return + } + + let button = WPStyleGuide.googleLoginButton() + stackView.addArrangedSubview(button) + button.addTarget(self, action: #selector(googleTapped), for: .touchUpInside) + + stackView.addConstraints([ + button.leadingAnchor.constraint(equalTo: instructionLabel.leadingAnchor), + button.trailingAnchor.constraint(equalTo: instructionLabel.trailingAnchor) + ]) + + googleLoginButton = button + } + + /// Add the log in with site address button to the view + /// + func addSelfHostedLogInButton() { + guard let instructionLabel = instructionLabel, + let stackView = inputStack else { + return + } + + let button = WPStyleGuide.selfHostedLoginButton() + stackView.addArrangedSubview(button) + button.addTarget(self, action: #selector(handleSelfHostedButtonTapped), for: .touchUpInside) + + stackView.addConstraints([ + button.leadingAnchor.constraint(equalTo: instructionLabel.leadingAnchor), + button.trailingAnchor.constraint(equalTo: instructionLabel.trailingAnchor) + ]) + + selfHostedLoginButton = button + } + + /// Add the sign up button + /// + /// Note: This is only used during Jetpack setup, not the normal flows + /// + func addSignupButton() { + guard let instructionLabel = instructionLabel, + let stackView = inputStack else { + return + } + + let button = WPStyleGuide.wpcomSignupButton() + stackView.addArrangedSubview(button) + + // Tapping the Sign up text link in "Don't have an account? _Sign up_" + // will present the 3 button view for signing up. + button.on(.touchUpInside) { [weak self] (_) in + guard let vc = LoginPrologueSignupMethodViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginPrologueSignupMethodViewController") + return + } + + guard let self = self else { return } + + vc.loginFields = self.loginFields + vc.dismissBlock = self.dismissBlock + vc.transitioningDelegate = self + vc.modalPresentationStyle = .custom + + // Don't forget to handle the button taps! + vc.emailTapped = { [weak self] in + guard let toVC = SignupEmailViewController.instantiate(from: .signup) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to SignupEmailViewController") + return + } + + self?.navigationController?.pushViewController(toVC, animated: true) + } + + vc.googleTapped = { [weak self] in + guard let self = self else { + return + } + + self.tracker.track(click: .signupWithGoogle) + + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + self.presentGoogleSignupView() + return + } + + self.presentUnifiedGoogleView() + } + + vc.appleTapped = { [weak self] in + self?.appleTapped() + } + + self.navigationController?.present(vc, animated: true, completion: nil) + } + + stackView.addConstraints([ + button.leadingAnchor.constraint(equalTo: instructionLabel.leadingAnchor), + button.trailingAnchor.constraint(equalTo: instructionLabel.trailingAnchor) + ]) + + wpcomSignupButton = button + } + + /// Configures the email text field, updating its text based on what's stored + /// in `loginFields`. + /// + func configureEmailField() { + emailTextField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + emailTextField.text = loginFields.username + emailTextField.adjustsFontForContentSizeCategory = true + hiddenPasswordField?.isAccessibilityElement = false + } + + private func configureAlternativeLabel() { + alternativeLoginLabel?.font = WPStyleGuide.fontForTextStyle(.subheadline) + alternativeLoginLabel?.textColor = WordPressAuthenticator.shared.style.subheadlineColor + } + + /// Configures whether appearance of the submit button. + /// + func configureSubmitButton() { + submitButton?.isEnabled = canSubmit() + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override open func configureViewLoading(_ loading: Bool) { + emailTextField.isEnabled = !loading + googleLoginButton?.isEnabled = !loading + + submitButton?.isEnabled = !loading + submitButton?.showActivityIndicator(loading) + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editiing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + emailTextField.becomeFirstResponder() + } + } + + // MARK: - Instance Methods + + /// Makes the call to retrieve Safari shared credentials if they exist. + /// + func fetchSharedWebCredentialsIfAvailable() { + didRequestSafariSharedCredentials = true + SafariCredentialsService.requestSharedWebCredentials { [weak self] (found, username, password) in + self?.handleFetchedWebCredentials(found, username: username, password: password) + } + } + + /// Handles Safari shared credentials if any where found. + /// + /// - Parameters: + /// - found: True if credentails were found. + /// - username: The selected username or nil. + /// - password: The selected password or nil. + /// + func handleFetchedWebCredentials(_ found: Bool, username: String?, password: String?) { + didFindSafariSharedCredentials = found + + guard let username = username, let password = password else { + return + } + + // Update the login fields + loginFields.username = username + loginFields.password = password + + // Persist credentials as autofilled credentials so we can update them later if needed. + loginFields.setStoredCredentials(usernameHash: username.hash, passwordHash: password.hash) + + loginWithUsernamePassword(immediately: true) + + WordPressAuthenticator.track(.loginAutoFillCredentialsFilled) + } + + /// Displays the wpcom sign in form, optionally telling it to immedately make + /// the call to authenticate with the available credentials. + /// + /// - Parameters: + /// - immediately: True if the newly loaded controller should immedately attempt + /// to authenticate the user with the available credentails. Default is `false`. + /// + func loginWithUsernamePassword(immediately: Bool = false) { + if immediately { + validateFormAndLogin() + } else { + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginWPComViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + } + + /// Proceeds along the "magic link" sign-in flow, showing a form that lets + /// the user request a magic link. + /// + func requestLink() { + guard let vc = LoginLinkRequestViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginLinkRequestViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. Empties loginFields.meta.socialService as + /// social signin does not require form validation. + /// + func validateForm() { + loginFields.meta.socialService = nil + displayError(message: "") + + guard EmailFormatValidator.validate(string: loginFields.username) else { + assertionFailure("Form should not be submitted unless there is a valid looking email entered.") + return + } + + configureViewLoading(true) + let service = WordPressComAccountService() + service.isPasswordlessAccount(username: loginFields.username, + success: { [weak self] passwordless in + self?.configureViewLoading(false) + self?.loginFields.meta.passwordless = passwordless + self?.requestLink() + }, + failure: { [weak self] error in + WordPressAuthenticator.track(.loginFailed, error: error) + WPAuthenticatorLogError(error.localizedDescription) + guard let strongSelf = self else { + return + } + strongSelf.configureViewLoading(false) + + let userInfo = (error as NSError).userInfo + let errorCode = userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String + if errorCode == "unknown_user" { + let msg = NSLocalizedString("This email address is not registered on WordPress.com.", + comment: "An error message informing the user the email address they entered did not match a WordPress.com account.") + strongSelf.displayError(message: msg) + } else if errorCode == "email_login_not_allowed" { + // If we get this error, we know we have a WordPress.com user but their + // email address is flagged as suspicious. They need to login via their + // username instead. + strongSelf.showSelfHostedUsernamePasswordAndError(error) + } else { + strongSelf.displayError(error, sourceTag: strongSelf.sourceTag) + } + }) + } + + /// When password autofill has entered a password on this screen, attempt to login immediately + func attemptAutofillLogin() { + loginFields.password = hiddenPasswordField?.text ?? "" + loginFields.meta.socialService = nil + displayError(message: "") + + loginWithUsernamePassword(immediately: true) + } + + /// Configures loginFields to log into wordpress.com and + /// navigates to the selfhosted username/password form. + /// Displays the specified error message when the new + /// view controller appears. + /// + @objc func showSelfHostedUsernamePasswordAndError(_ error: Error) { + loginFields.siteAddress = "https://wordpress.com" + errorToPresent = error + + guard let vc = LoginSelfHostedViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginSelfHostedViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Whether the form can be submitted. + /// + func canSubmit() -> Bool { + return EmailFormatValidator.validate(string: loginFields.username) + } + + // MARK: - Actions + + @IBAction func handleSubmitForm() { + if canSubmit() { + validateForm() + } + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + validateForm() + } + + @IBAction func handleSelfHostedButtonTapped(_ sender: UIButton) { + loginToSelfHostedSite() + } + + private func appleTapped() { + AppleAuthenticator.sharedInstance.delegate = self + AppleAuthenticator.sharedInstance.showFrom(viewController: self) + } + + @objc func googleTapped() { + self.tracker.track(click: .loginWithGoogle) + + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + GoogleAuthenticator.sharedInstance.loginDelegate = self + GoogleAuthenticator.sharedInstance.showFrom(viewController: self, loginFields: loginFields, for: .login) + return + } + + presentUnifiedGoogleView() + } + + // Shows the VC that handles both Google login & signup. + private func presentUnifiedGoogleView() { + guard let toVC = GoogleAuthViewController.instantiate(from: .googleAuth) else { + WPAuthenticatorLogError("Failed to navigate to GoogleAuthViewController from LoginPrologueVC") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + // Shows the VC that handles only Google signup. + private func presentGoogleSignupView() { + guard let toVC = SignupGoogleViewController.instantiate(from: .signup) else { + WPAuthenticatorLogError("Failed to navigate to SignupGoogleViewController from LoginEmailVC") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + switch sender { + case emailTextField: + loginFields.username = emailTextField.nonNilTrimmedText() + configureSubmitButton() + case hiddenPasswordField: + attemptAutofillLogin() + default: + break + } + } + + @IBAction func handleTextFieldEditingDidBegin(_ sender: UITextField) { + if !didRequestSafariSharedCredentials { + fetchSharedWebCredentialsIfAvailable() + } + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + + adjustAlternativeLogInElementsVisibility(true) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + + adjustAlternativeLogInElementsVisibility(false) + } + + func adjustAlternativeLogInElementsVisibility(_ visible: Bool) { + let errorLength = errorLabel?.text?.count ?? 0 + let keyboardTallEnough = SigninEditingState.signinLastKeyboardHeightDelta > Constants.keyboardThreshold + let keyboardVisible = visible && keyboardTallEnough + + let baseAlpha: CGFloat = errorLength > 0 ? 0.0 : 1.0 + let newAlpha: CGFloat = keyboardVisible ? baseAlpha : 1.0 + + UIView.animate(withDuration: Constants.alternativeLogInAnimationDuration) { [weak self] in + self?.alternativeLoginLabel?.alpha = newAlpha + self?.googleLoginButton?.alpha = newAlpha + if let selfHostedLoginButton = self?.selfHostedLoginButton, + selfHostedLoginButton.isEnabled { + selfHostedLoginButton.alpha = newAlpha + } + } + } + +} + +// MARK: - AppleAuthenticatorDelegate + +extension LoginEmailViewController: AppleAuthenticatorDelegate { + + func showWPComLogin(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginWPComViewController") + return + } + + vc.loginFields = self.loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + func showApple2FA(loginFields: LoginFields) { + self.loginFields = loginFields + signInAppleAccount() + } + + func authFailedWithError(message: String) { + displayErrorAlert(message, sourceTag: .loginApple) + } + +} + +// MARK: - GoogleAuthenticatorLoginDelegate + +extension LoginEmailViewController: GoogleAuthenticatorLoginDelegate { + + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + syncWPComAndPresentEpilogue(credentials: credentials) + } + + func googleNeedsMultifactorCode(loginFields: LoginFields) { + self.loginFields = loginFields + configureViewLoading(false) + + guard let vc = Login2FAViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to Login2FAViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + func googleExistingUserNeedsConnection(loginFields: LoginFields) { + self.loginFields = loginFields + configureViewLoading(false) + + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from Google Login to LoginWPComViewController (password VC)") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields) { + self.loginFields = loginFields + configureViewLoading(false) + + let socialErrorVC = LoginSocialErrorViewController(title: errorTitle, description: errorDescription) + let socialErrorNav = LoginNavigationController(rootViewController: socialErrorVC) + socialErrorVC.delegate = self + socialErrorVC.loginFields = loginFields + socialErrorVC.modalPresentationStyle = .fullScreen + present(socialErrorNav, animated: true) + } + +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginLinkRequestViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginLinkRequestViewController.swift new file mode 100644 index 000000000000..9d5b915661c5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginLinkRequestViewController.swift @@ -0,0 +1,172 @@ +import UIKit +import WordPressShared + +/// Step one in the auth link flow. This VC displays a form to request a "magic" +/// authentication link be emailed to the user. Allows the user to signin via +/// email instead of their password. +/// +class LoginLinkRequestViewController: LoginViewController { + @IBOutlet var gravatarView: UIImageView? + @IBOutlet var label: UILabel? + @IBOutlet var sendLinkButton: NUXButton? + @IBOutlet var usePasswordButton: UIButton? + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginMagicLink + } + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + localizeControls() + configureUsePasswordButton() + + let email = loginFields.username + if !email.isValidEmail() { + assert(email.isValidEmail(), "The value of loginFields.username was not a valid email address.") + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + let email = loginFields.username + if email.isValidEmail() { + gravatarView?.downloadGravatarWithEmail(email, rating: .x) + } else { + gravatarView?.isHidden = true + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + WordPressAuthenticator.track(.loginMagicLinkRequestFormViewed) + } + + // MARK: - Configuration + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + let format = NSLocalizedString("We'll email you a magic link that'll log you in instantly, no password needed. Hunt and peck no more!", comment: "Instructional text for the magic link login flow.") + label?.text = NSString(format: format as NSString, loginFields.username) as String + label?.font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + label?.textColor = WordPressAuthenticator.shared.style.instructionColor + label?.adjustsFontForContentSizeCategory = true + + let sendLinkButtonTitle = NSLocalizedString("Send Link", comment: "Title of a button. The text should be uppercase. Clicking requests a hyperlink be emailed ot the user.") + sendLinkButton?.setTitle(sendLinkButtonTitle, for: .normal) + sendLinkButton?.setTitle(sendLinkButtonTitle, for: .highlighted) + sendLinkButton?.accessibilityIdentifier = "Send Link Button" + + let usePasswordTitle = NSLocalizedString("Enter your password instead.", comment: "Title of a button. ") + usePasswordButton?.setTitle(usePasswordTitle, for: .normal) + usePasswordButton?.setTitle(usePasswordTitle, for: .highlighted) + usePasswordButton?.titleLabel?.numberOfLines = 0 + usePasswordButton?.titleLabel?.textAlignment = .center + usePasswordButton?.accessibilityIdentifier = "Use Password" + } + + @objc func configureLoading(_ animating: Bool) { + sendLinkButton?.showActivityIndicator(animating) + + sendLinkButton?.isEnabled = !animating + } + + private func configureUsePasswordButton() { + guard let usePasswordButton = usePasswordButton else { + return + } + WPStyleGuide.configureTextButton(usePasswordButton) + } + + // MARK: - Instance Methods + + /// Makes the call to request a magic authentication link be emailed to the user. + /// + @objc func requestAuthenticationLink() { + + loginFields.meta.emailMagicLinkSource = .login + + let email = loginFields.username + guard email.isValidEmail() else { + // This is a bit of paranoia as in practice it should never happen. + // However, let's make sure we give the user some useful feedback just in case. + WPAuthenticatorLogError("Attempted to request authentication link, but the email address did not appear valid.") + let alert = UIAlertController(title: NSLocalizedString("Can Not Request Link", comment: "Title of an alert letting the user know"), message: NSLocalizedString("A valid email address is needed to mail an authentication link. Please return to the previous screen and provide a valid email address.", comment: "An error message."), preferredStyle: .alert) + alert.addActionWithTitle(NSLocalizedString("Need help?", comment: "Takes the user to get help"), style: .cancel, handler: { _ in WordPressAuthenticator.shared.delegate?.presentSupportRequest(from: self, sourceTag: .loginEmail) }) + alert.addActionWithTitle(NSLocalizedString("OK", comment: "Dismisses the alert"), style: .default, handler: nil) + self.present(alert, animated: true, completion: nil) + return + } + + configureLoading(true) + let service = WordPressComAccountService() + service.requestAuthenticationLink(for: email, + jetpackLogin: loginFields.meta.jetpackLogin, + success: { [weak self] in + self?.didRequestAuthenticationLink() + self?.configureLoading(false) + + }, failure: { [weak self] (error: Error) in + WordPressAuthenticator.track(.loginMagicLinkFailed) + WordPressAuthenticator.track(.loginFailed, error: error) + guard let strongSelf = self else { + return + } + strongSelf.displayError(error, sourceTag: strongSelf.sourceTag) + strongSelf.configureLoading(false) + }) + } + + // MARK: - Dynamic type + override func didChangePreferredContentSize() { + label?.font = WPStyleGuide.fontForTextStyle(.headline) + } + + // MARK: - Actions + + @IBAction func handleUsePasswordTapped(_ sender: UIButton) { + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginLinkRequestViewController to LoginWPComViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + WordPressAuthenticator.track(.loginMagicLinkExited) + } + + @IBAction func handleSendLinkTapped(_ sender: UIButton) { + requestAuthenticationLink() + } + + @objc func didRequestAuthenticationLink() { + WordPressAuthenticator.track(.loginMagicLinkRequested) + + guard let vc = NUXLinkMailViewController.instantiate(from: .emailMagicLink) else { + WPAuthenticatorLogError("Failed to navigate to NUXLinkMailViewController") + return + } + + vc.loginFields = self.loginFields + vc.loginFields.restrictToWPCom = true + navigationController?.pushViewController(vc, animated: true) + } +} + +extension LoginLinkRequestViewController { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginNavigationController.swift b/WordPressAuthenticator/Sources/Signin/LoginNavigationController.swift new file mode 100644 index 000000000000..dcf7af36b842 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginNavigationController.swift @@ -0,0 +1,23 @@ +import UIKit +import WordPressShared +import WordPressUI + +public class LoginNavigationController: RotationAwareNavigationViewController { + + public override var preferredStatusBarStyle: UIStatusBarStyle { + return topViewController?.preferredStatusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + public override func pushViewController(_ viewController: UIViewController, animated: Bool) { + // By default, the back button label uses the previous view's title. + // To override that, reset the label when pushing a new view controller. + if #available(iOS 14.0, *) { + self.viewControllers.last?.navigationItem.backButtonDisplayMode = .minimal + } else { + self.viewControllers.last?.navigationItem.backBarButtonItem = UIBarButtonItem(image: UIImage(), style: .plain, target: nil, action: nil) + } + + super.pushViewController(viewController, animated: animated) + } + +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginPrologueLoginMethodViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginPrologueLoginMethodViewController.swift new file mode 100644 index 000000000000..3362cf939db6 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginPrologueLoginMethodViewController.swift @@ -0,0 +1,132 @@ +import WordPressUI +import WordPressShared + +/// This class houses the "3 button view": +/// Continue with WordPress.com, Continue with Google, Continue with Apple +/// and a text link - Or log in by entering your site address. +/// +class LoginPrologueLoginMethodViewController: NUXViewController { + /// Buttons at bottom of screen + private var buttonViewController: NUXButtonViewController? + + /// Gesture recognizer for taps on the dialog if no buttons are present + fileprivate var dismissGestureRecognizer: UITapGestureRecognizer? + + open var emailTapped: (() -> Void)? + open var googleTapped: (() -> Void)? + open var selfHostedTapped: (() -> Void)? + open var appleTapped: (() -> Void)? + + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + /// The big transparent (dismiss) button behind the buttons + @IBOutlet private weak var dismissButton: UIButton! + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let vc = segue.destination as? NUXButtonViewController { + buttonViewController = vc + } + } + + override func viewDidLoad() { + super.viewDidLoad() + configureButtonVC() + configureForAccessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.setNavigationBarHidden(true, animated: false) + } + + private func configureButtonVC() { + guard let buttonViewController = buttonViewController else { + return + } + + let wordpressTitle = NSLocalizedString("Log in or sign up with WordPress.com", comment: "Button title. Tapping begins our normal log in process.") + buttonViewController.setupTopButton(title: wordpressTitle, isPrimary: false, accessibilityIdentifier: "Log in with Email Button") { [weak self] in + + guard let self = self else { + return + } + + self.tracker.set(flow: .wpCom) + self.dismiss(animated: true) + self.emailTapped?() + } + + buttonViewController.setupButtomButtonFor(socialService: .google) { [weak self] in + self?.handleGoogleButtonTapped() + } + + if !LoginFields().restrictToWPCom && selfHostedTapped != nil { + let selfHostedLoginButton = WPStyleGuide.selfHostedLoginButton(alignment: .center) + buttonViewController.stackView?.addArrangedSubview(selfHostedLoginButton) + selfHostedLoginButton.addTarget(self, action: #selector(handleSelfHostedButtonTapped), for: .touchUpInside) + } + + if WordPressAuthenticator.shared.configuration.enableSignInWithApple { + buttonViewController.setupTertiaryButtonFor(socialService: .apple) { [weak self] in + self?.handleAppleButtonTapped() + } + } + + buttonViewController.backgroundColor = WordPressAuthenticator.shared.style.buttonViewBackgroundColor + } + + @IBAction func dismissTapped() { + dismiss(animated: true) + } + + @IBAction func handleSelfHostedButtonTapped(_ sender: UIButton) { + dismiss(animated: true) + + tracker.set(flow: .loginWithSiteAddress) + tracker.track(click: .loginWithSiteAddress) + + selfHostedTapped?() + } + + @objc func handleAppleButtonTapped() { + tracker.set(flow: .loginWithApple) + tracker.track(click: .loginWithApple, ifTrackingNotEnabled: { + WordPressAuthenticator.track(.loginSocialButtonClick, properties: ["source": "apple"]) + }) + + dismiss(animated: true) + appleTapped?() + } + + @objc func handleGoogleButtonTapped() { + tracker.set(flow: .loginWithGoogle) + tracker.track(click: .loginWithGoogle) + + dismiss(animated: true) + googleTapped?() + } + + // MARK: - Accessibility + + private func configureForAccessibility() { + dismissButton.accessibilityLabel = NSLocalizedString("Dismiss", comment: "Accessibility label for the transparent space above the login dialog which acts as a button to dismiss the dialog.") + + // Ensure that the first button (in buttonViewController) is automatically selected by + // VoiceOver instead of the dismiss button. + if buttonViewController?.isViewLoaded == true, let buttonsView = buttonViewController?.view { + view.accessibilityElements = [ + buttonsView, + dismissButton as Any + ] + } + } + + override func accessibilityPerformEscape() -> Bool { + dismiss(animated: true) + return true + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginProloguePageViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginProloguePageViewController.swift new file mode 100644 index 000000000000..1e9875e32d82 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginProloguePageViewController.swift @@ -0,0 +1,98 @@ +import UIKit +import WordPressShared + +class LoginProloguePageViewController: UIPageViewController { + // This property is a legacy of the previous UX iteration. It ought to be removed, but that's + // out of scope at the time of writing. It's now `private` to prevent using it within the + // library in the meantime + @objc private var pages: [UIViewController] = [] + + fileprivate var pageControl: UIPageControl? + fileprivate var bgAnimation: UIViewPropertyAnimator? + fileprivate struct Constants { + static let pagerPadding: CGFloat = 9.0 + static let pagerHeight: CGFloat = 0.13 + } + + override func viewDidLoad() { + super.viewDidLoad() + dataSource = self + delegate = self + + view.backgroundColor = WordPressAuthenticator.shared.style.prologueBackgroundColor + + addPageControl() + } + + @objc func addPageControl() { + let newControl = UIPageControl() + + newControl.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(newControl) + + newControl.topAnchor.constraint(equalTo: view.topAnchor, constant: Constants.pagerPadding).isActive = true + newControl.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true + newControl.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true + newControl.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: Constants.pagerHeight).isActive = true + + newControl.numberOfPages = pages.count + newControl.addTarget(self, action: #selector(handlePageControlValueChanged(sender:)), for: .valueChanged) + pageControl = newControl + } + + @objc func handlePageControlValueChanged(sender: UIPageControl) { + guard let currentPage = viewControllers?.first, + let currentIndex = pages.firstIndex(of: currentPage) else { + return + } + + let direction: UIPageViewController.NavigationDirection = sender.currentPage > currentIndex ? .forward : .reverse + setViewControllers([pages[sender.currentPage]], direction: direction, animated: true) + WordPressAuthenticator.track(.loginProloguePaged) + } +} + +extension LoginProloguePageViewController: UIPageViewControllerDataSource { + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController) else { + return nil + } + if index > 0 { + return pages[index - 1] + } + return nil + } + + func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController) else { + return nil + } + if index < pages.count - 1 { + return pages[index + 1] + } + return nil + } +} + +extension LoginProloguePageViewController: UIPageViewControllerDelegate { + func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { + let toVC = previousViewControllers[0] + guard let index = pages.firstIndex(of: toVC) else { + return + } + if !completed { + pageControl?.currentPage = index + } else { + WordPressAuthenticator.track(.loginProloguePaged) + } + } + + func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { + let toVC = pendingViewControllers[0] + guard let index = pages.firstIndex(of: toVC) else { + return + } + pageControl?.currentPage = index + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginPrologueSignupMethodViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginPrologueSignupMethodViewController.swift new file mode 100644 index 000000000000..874aa7b59ccb --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginPrologueSignupMethodViewController.swift @@ -0,0 +1,134 @@ +import SafariServices +import WordPressUI +import WordPressShared + +class LoginPrologueSignupMethodViewController: NUXViewController { + /// Buttons at bottom of screen + private var buttonViewController: NUXButtonViewController? + + /// Gesture recognizer for taps on the dialog if no buttons are present + fileprivate var dismissGestureRecognizer: UITapGestureRecognizer? + + open var emailTapped: (() -> Void)? + open var googleTapped: (() -> Void)? + open var appleTapped: (() -> Void)? + + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + /// The big transparent (dismiss) button behind the buttons + @IBOutlet private weak var dismissButton: UIButton! + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let vc = segue.destination as? NUXButtonViewController { + buttonViewController = vc + } + } + + override func viewDidLoad() { + super.viewDidLoad() + configureButtonVC() + configureForAccessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + self.navigationController?.setNavigationBarHidden(true, animated: false) + } + + private func configureButtonVC() { + guard let buttonViewController = buttonViewController else { + return + } + + let loginTitle = NSLocalizedString("Sign up with Email", comment: "Button title. Tapping begins our normal sign up process.") + buttonViewController.setupTopButton(title: loginTitle, isPrimary: false, accessibilityIdentifier: "Sign up with Email Button") { [weak self] in + + self?.tracker.set(flow: .wpCom) + + defer { + WordPressAuthenticator.track(.signupEmailButtonTapped) + } + self?.dismiss(animated: true) + self?.emailTapped?() + } + + buttonViewController.setupButtomButtonFor(socialService: .google) { [weak self] in + self?.handleGoogleButtonTapped() + } + + let termsButton = WPStyleGuide.termsButton() + termsButton.on(.touchUpInside) { [weak self] _ in + defer { + self?.tracker.track(click: .termsOfService, ifTrackingNotEnabled: { + WordPressAuthenticator.track(.signupTermsButtonTapped) + }) + } + + let safariViewController = SFSafariViewController(url: WordPressAuthenticator.shared.configuration.wpcomTermsOfServiceURL) + safariViewController.modalPresentationStyle = .pageSheet + self?.present(safariViewController, animated: true, completion: nil) + } + buttonViewController.stackView?.insertArrangedSubview(termsButton, at: 0) + + if WordPressAuthenticator.shared.configuration.enableSignInWithApple { + buttonViewController.setupTertiaryButtonFor(socialService: .apple) { [weak self] in + self?.handleAppleButtonTapped() + } + } + + buttonViewController.backgroundColor = WordPressAuthenticator.shared.style.buttonViewBackgroundColor + } + + @IBAction func dismissTapped() { + trackCancellationAndThenDismiss() + } + + @objc func handleAppleButtonTapped() { + tracker.set(flow: .signupWithApple) + tracker.track(click: .signupWithApple, ifTrackingNotEnabled: { + WordPressAuthenticator.track(.signupSocialButtonTapped, properties: ["source": "apple"]) + }) + + dismiss(animated: true) + appleTapped?() + } + + @objc func handleGoogleButtonTapped() { + tracker.set(flow: .signupWithGoogle) + tracker.track(click: .signupWithGoogle, ifTrackingNotEnabled: { + WordPressAuthenticator.track(.signupSocialButtonTapped, properties: ["source": "google"]) + }) + + dismiss(animated: true) + googleTapped?() + } + + private func trackCancellationAndThenDismiss() { + WordPressAuthenticator.track(.signupCancelled) + dismiss(animated: true) + } + + // MARK: - Accessibility + + private func configureForAccessibility() { + dismissButton.accessibilityLabel = NSLocalizedString("Dismiss", comment: "Accessibility label for the transparent space above the signup dialog which acts as a button to dismiss the dialog.") + + // Ensure that the first button (in buttonViewController) is automatically selected by + // VoiceOver instead of the dismiss button. + if buttonViewController?.isViewLoaded == true, let buttonsView = buttonViewController?.view { + view.accessibilityElements = [ + buttonsView, + dismissButton as Any + ] + } + } + + override func accessibilityPerformEscape() -> Bool { + trackCancellationAndThenDismiss() + return true + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift new file mode 100644 index 000000000000..e7c4fe7d30da --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginPrologueViewController.swift @@ -0,0 +1,756 @@ +import UIKit +import WordPressShared +import WordPressUI +import WordPressKit + +class LoginPrologueViewController: LoginViewController { + + @IBOutlet private weak var topContainerView: UIView! + @IBOutlet private weak var buttonBlurEffectView: UIVisualEffectView! + @IBOutlet private weak var buttonBackgroundView: UIView! + private var buttonViewController: NUXButtonViewController? + private var stackedButtonsViewController: NUXStackedButtonsViewController? + var showCancel = false + + @IBOutlet private weak var buttonContainerView: UIView! + /// Blur effect on button container view + /// + private var blurEffect: UIBlurEffect.Style { + return .systemChromeMaterial + } + + /// Constraints on the button view container. + /// Used to adjust the button width in unified views. + @IBOutlet private weak var buttonViewLeadingConstraint: NSLayoutConstraint? + @IBOutlet private weak var buttonViewTrailingConstraint: NSLayoutConstraint? + private var defaultButtonViewMargin: CGFloat = 0 + + // Called when login button is tapped + var onLoginButtonTapped: (() -> Void)? + + private let configuration = WordPressAuthenticator.shared.configuration + private let style = WordPressAuthenticator.shared.style + + private lazy var storedCredentialsAuthenticator = StoredCredentialsAuthenticator(onCancel: { [weak self] in + // Since the authenticator has its own flow + self?.tracker.resetState() + }) + + /// We can't rely on `isMovingToParent` to know if we need to track the `.prologue` step + /// because for the root view in an App, it's always `false`. We're relying this variiable + /// instead, since the `.prologue` step only needs to be tracked once. + /// + private var prologueFlowTracked = false + + /// Return`true` to use new `NUXStackedButtonsViewController` instead of `NUXButtonViewController` to create buttons + /// + private var useStackedButtonsViewController: Bool { + configuration.enableWPComLoginOnlyInPrologue || + configuration.enableSiteCreation || + configuration.enableSiteAddressLoginOnlyInPrologue || + configuration.enableSiteCreationGuide + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + if let topContainerChildViewController = style.prologueTopContainerChildViewController() { + topContainerView.subviews.forEach { $0.removeFromSuperview() } + addChild(topContainerChildViewController) + topContainerView.addSubview(topContainerChildViewController.view) + topContainerChildViewController.didMove(toParent: self) + + topContainerChildViewController.view.translatesAutoresizingMaskIntoConstraints = false + topContainerView.pinSubviewToAllEdges(topContainerChildViewController.view) + } + + createButtonViewController() + + defaultButtonViewMargin = buttonViewLeadingConstraint?.constant ?? 0 + if let backgroundImage = WordPressAuthenticator.shared.unifiedStyle?.prologueBackgroundImage { + view.layer.contents = backgroundImage.cgImage + } + } + + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + configureButtonVC() + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + // We've found some instances where the iCloud Keychain login flow was being started + // when the device was idle and the app was logged out and in the background. I couldn't + // find precise reproduction steps for this issue but my guess is that some background + // operation is triggering a call to this method while the app is in the background. + // The proposed solution is based off this StackOverflow reply: + // + // https://stackoverflow.com/questions/30584356/viewdidappear-is-called-when-app-is-started-due-to-significant-location-change + // + guard UIApplication.shared.applicationState != .background else { + return + } + + WordPressAuthenticator.track(.loginPrologueViewed) + + tracker.set(flow: .prologue) + + if !prologueFlowTracked { + tracker.track(step: .prologue) + prologueFlowTracked = true + } else { + tracker.set(step: .prologue) + } + + // Only enable auto fill if WPCom login is available + if configuration.enableSiteAddressLoginOnlyInPrologue == false { + showiCloudKeychainLoginFlow() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + self.navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.isPad() ? .all : .portrait + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + setButtonViewMargins(forWidth: view.frame.width) + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + setButtonViewMargins(forWidth: size.width) + } + + // MARK: - iCloud Keychain Login + + /// Starts the iCloud Keychain login flow if the conditions are given. + /// + private func showiCloudKeychainLoginFlow() { + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth, + let navigationController = navigationController else { + return + } + + storedCredentialsAuthenticator.showPicker(from: navigationController) + } + + private func configureButtonVC() { + guard configuration.enableUnifiedAuth else { + buildPrologueButtons() + return + } + + if useStackedButtonsViewController { + buildPrologueButtonsUsingStackedButtonsViewController() + } else { + buildUnifiedPrologueButtons() + } + + if let buttonViewController = buttonViewController { + buttonViewController.shadowLayoutGuide = view.safeAreaLayoutGuide + buttonViewController.topButtonStyle = WordPressAuthenticator.shared.style.prologuePrimaryButtonStyle + buttonViewController.bottomButtonStyle = WordPressAuthenticator.shared.style.prologueSecondaryButtonStyle + buttonViewController.tertiaryButtonStyle = WordPressAuthenticator.shared.style.prologueSecondaryButtonStyle + } else if let stackedButtonsViewController = stackedButtonsViewController { + stackedButtonsViewController.shadowLayoutGuide = view.safeAreaLayoutGuide + } + } + + /// Displays the old UI prologue buttons. + /// + private func buildPrologueButtons() { + guard let buttonViewController = buttonViewController else { + return + } + + let loginTitle = NSLocalizedString("Log In", comment: "Button title. Tapping takes the user to the login form.") + let createTitle = NSLocalizedString("Sign up for WordPress.com", comment: "Button title. Tapping begins the process of creating a WordPress.com account.") + + buttonViewController.setupTopButton(title: loginTitle, isPrimary: false, accessibilityIdentifier: "Prologue Log In Button") { [weak self] in + self?.onLoginButtonTapped?() + self?.loginTapped() + } + + if configuration.enableSignUp { + buttonViewController.setupBottomButton(title: createTitle, isPrimary: true, accessibilityIdentifier: "Prologue Signup Button") { [weak self] in + self?.signupTapped() + } + } + + if showCancel { + let cancelTitle = NSLocalizedString("Cancel", comment: "Button title. Tapping it cancels the login flow.") + buttonViewController.setupTertiaryButton(title: cancelTitle, isPrimary: false) { [weak self] in + self?.dismiss(animated: true, completion: nil) + } + } + + buttonViewController.backgroundColor = style.buttonViewBackgroundColor + buttonBlurEffectView.isHidden = true + } + + /// Displays the Unified prologue buttons. + /// + private func buildUnifiedPrologueButtons() { + guard let buttonViewController = buttonViewController else { + return + } + + let displayStrings = WordPressAuthenticator.shared.displayStrings + let loginTitle = displayStrings.continueWithWPButtonTitle + let siteAddressTitle = displayStrings.enterYourSiteAddressButtonTitle + + if configuration.continueWithSiteAddressFirst { + buildUnifiedPrologueButtonsWithSiteAddressFirst(buttonViewController, loginTitle: loginTitle, siteAddressTitle: siteAddressTitle) + return + } + + buildDefaultUnifiedPrologueButtons(buttonViewController, loginTitle: loginTitle, siteAddressTitle: siteAddressTitle) + } + + private func buildDefaultUnifiedPrologueButtons(_ buttonViewController: NUXButtonViewController, loginTitle: String, siteAddressTitle: String) { + + setButtonViewMargins(forWidth: view.frame.width) + + buttonViewController.setupTopButton(title: loginTitle, isPrimary: true, configureBodyFontForTitle: true, accessibilityIdentifier: "Prologue Continue Button", onTap: loginTapCallback()) + + if configuration.enableUnifiedAuth { + buttonViewController.setupBottomButton(title: siteAddressTitle, isPrimary: false, configureBodyFontForTitle: true, accessibilityIdentifier: "Prologue Self Hosted Button", onTap: siteAddressTapCallback()) + } + + showCancelIfNeccessary(buttonViewController) + + setButtonViewControllerBackground() + } + + private func buildUnifiedPrologueButtonsWithSiteAddressFirst(_ buttonViewController: NUXButtonViewController, loginTitle: String, siteAddressTitle: String) { + guard configuration.enableUnifiedAuth == true else { + return + } + + setButtonViewMargins(forWidth: view.frame.width) + + buttonViewController.setupTopButton(title: siteAddressTitle, isPrimary: true, accessibilityIdentifier: "Prologue Self Hosted Button", onTap: siteAddressTapCallback()) + + buttonViewController.setupBottomButton(title: loginTitle, isPrimary: false, accessibilityIdentifier: "Prologue Continue Button", onTap: loginTapCallback()) + + showCancelIfNeccessary(buttonViewController) + + setButtonViewControllerBackground() + } + + private func buildPrologueButtonsUsingStackedButtonsViewController() { + guard let stackedButtonsViewController = stackedButtonsViewController else { + return + } + + let primaryButtonStyle = WordPressAuthenticator.shared.style.prologuePrimaryButtonStyle + let secondaryButtonStyle = WordPressAuthenticator.shared.style.prologueSecondaryButtonStyle + + setButtonViewMargins(forWidth: view.frame.width) + let displayStrings = WordPressAuthenticator.shared.displayStrings + let buttons: [StackedButton] + + let continueWithWPButton: StackedButton? = { + guard !configuration.enableSiteAddressLoginOnlyInPrologue else { + return nil + } + return StackedButton(title: displayStrings.continueWithWPButtonTitle, + isPrimary: true, + configureBodyFontForTitle: true, + accessibilityIdentifier: "Prologue Continue Button", + style: primaryButtonStyle, + onTap: loginTapCallback()) + }() + + let enterYourSiteAddressButton: StackedButton? = { + guard !configuration.enableWPComLoginOnlyInPrologue else { + return nil + } + let isPrimary = configuration.enableSiteAddressLoginOnlyInPrologue && !configuration.enableSiteCreation + return StackedButton(title: displayStrings.enterYourSiteAddressButtonTitle, + isPrimary: isPrimary, + configureBodyFontForTitle: true, + accessibilityIdentifier: "Prologue Self Hosted Button", + style: secondaryButtonStyle, + onTap: siteAddressTapCallback()) + }() + + let createSiteButton: StackedButton? = { + guard configuration.enableSiteCreation else { + return nil + } + let isPrimary = configuration.enableSiteAddressLoginOnlyInPrologue + return StackedButton(title: displayStrings.siteCreationButtonTitle, + isPrimary: isPrimary, + configureBodyFontForTitle: true, + accessibilityIdentifier: "Prologue Create Site Button", + style: secondaryButtonStyle, + onTap: simplifiedLoginSiteCreationCallback()) + }() + + let createSiteButtonForBottomStackView: StackedButton? = { + guard let createSiteButton else { + return nil + } + return StackedButton(using: createSiteButton, stackView: .bottom) + }() + + let siteCreationGuideButton: StackedButton? = { + guard configuration.enableSiteCreationGuide else { + return nil + } + return StackedButton(title: displayStrings.siteCreationGuideButtonTitle, + isPrimary: false, + configureBodyFontForTitle: true, + accessibilityIdentifier: "Prologue Site Creation Guide button", + style: NUXButtonStyle.linkButtonStyle, + onTap: siteCreationGuideCallback()) + }() + + let showBothLoginOptions = continueWithWPButton != nil && enterYourSiteAddressButton != nil + buttons = [ + continueWithWPButton, + !showBothLoginOptions ? createSiteButton : nil, + enterYourSiteAddressButton, + showBothLoginOptions ? createSiteButtonForBottomStackView : nil, + siteCreationGuideButton + ].compactMap { $0 } + + let showDivider = configuration.enableWPComLoginOnlyInPrologue == false && + configuration.enableSiteCreation == true && + configuration.enableSiteAddressLoginOnlyInPrologue == false + stackedButtonsViewController.setUpButtons(using: buttons, showDivider: showDivider) + setButtonViewControllerBackground() + } + + private func siteAddressTapCallback() -> NUXButtonViewController.CallBackType { + return { [weak self] in + self?.siteAddressTapped() + } + } + + private func loginTapCallback() -> NUXButtonViewController.CallBackType { + return { [weak self] in + guard let self = self else { + return + } + + self.tracker.track(click: .continueWithWordPressCom) + self.continueWithDotCom() + } + } + + private func simplifiedLoginSiteCreationCallback() -> NUXButtonViewController.CallBackType { + { [weak self] in + guard let self = self, let navigationController = self.navigationController else { return } + // triggers the delegate to ask the host app to handle site creation + WordPressAuthenticator.shared.delegate?.showSiteCreation(in: navigationController) + } + } + + private func siteCreationGuideCallback() -> NUXButtonViewController.CallBackType { + { [weak self] in + guard let self, let navigationController else { return } + // triggers the delegate to ask the host app to handle site creation guide + WordPressAuthenticator.shared.delegate?.showSiteCreationGuide(in: navigationController) + } + } + + private func showCancelIfNeccessary(_ buttonViewController: NUXButtonViewController) { + if showCancel { + let cancelTitle = NSLocalizedString("Cancel", comment: "Button title. Tapping it cancels the login flow.") + buttonViewController.setupTertiaryButton(title: cancelTitle, isPrimary: false) { [weak self] in + self?.dismiss(animated: true, completion: nil) + } + } + } + + private func setButtonViewControllerBackground() { + // Fallback to setting the button background color to clear so the blur effect blurs the Prologue background color. + let buttonsBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.prologueButtonsBackgroundColor ?? .clear + buttonViewController?.backgroundColor = buttonsBackgroundColor + buttonBackgroundView?.backgroundColor = buttonsBackgroundColor + stackedButtonsViewController?.backgroundColor = buttonsBackgroundColor + + /// If host apps provide a background color for the prologue buttons: + /// 1. Hide the blur effect + /// 2. Set the background color of the view controller to prologueViewBackgroundColor + let prologueViewBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.prologueViewBackgroundColor ?? .clear + + guard prologueViewBackgroundColor.cgColor == buttonsBackgroundColor.cgColor else { + buttonBlurEffectView.effect = UIBlurEffect(style: blurEffect) + return + } + // do not set background color if we've set a background image earlier + if WordPressAuthenticator.shared.unifiedStyle?.prologueBackgroundImage == nil { + view.backgroundColor = prologueViewBackgroundColor + } + // if a blur effect for the buttons was passed, use it; otherwise hide the view. + guard let blurEffect = WordPressAuthenticator.shared.unifiedStyle?.prologueButtonsBlurEffect else { + buttonBlurEffectView.isHidden = true + return + } + buttonBlurEffectView.effect = blurEffect + } + + // MARK: - Actions + + /// Old UI. "Log In" button action. + /// + private func loginTapped() { + tracker.set(source: .default) + + guard let vc = LoginPrologueLoginMethodViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginPrologueLoginMethodViewController from LoginPrologueViewController") + return + } + + vc.transitioningDelegate = self + + // Continue with WordPress.com button action + vc.emailTapped = { [weak self] in + guard let self = self else { + return + } + + self.presentLoginEmailView() + } + + // Continue with Google button action + vc.googleTapped = { [weak self] in + self?.googleTapped() + } + + // Site address text link button action + vc.selfHostedTapped = { [weak self] in + self?.loginToSelfHostedSite() + } + + // Sign In With Apple (SIWA) button action + vc.appleTapped = { [weak self] in + self?.appleTapped() + } + + vc.modalPresentationStyle = .custom + navigationController?.present(vc, animated: true, completion: nil) + } + + /// Old UI. "Sign up with WordPress.com" button action. + /// + private func signupTapped() { + tracker.set(source: .default) + + // This stat is part of a funnel that provides critical information. + // Before making ANY modification to this stat please refer to: p4qSXL-35X-p2 + WordPressAuthenticator.track(.signupButtonTapped) + + guard let vc = LoginPrologueSignupMethodViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginPrologueSignupMethodViewController") + return + } + + vc.loginFields = self.loginFields + vc.dismissBlock = dismissBlock + vc.transitioningDelegate = self + vc.modalPresentationStyle = .custom + + vc.emailTapped = { [weak self] in + guard let self = self else { + return + } + + guard self.configuration.enableUnifiedAuth else { + self.presentSignUpEmailView() + return + } + + self.presentUnifiedSignupView() + } + + vc.googleTapped = { [weak self] in + guard let self = self else { + return + } + + guard self.configuration.enableUnifiedAuth else { + self.presentGoogleSignupView() + return + } + + self.presentUnifiedGoogleView() + } + + vc.appleTapped = { [weak self] in + self?.appleTapped() + } + + navigationController?.present(vc, animated: true, completion: nil) + } + + private func appleTapped() { + AppleAuthenticator.sharedInstance.delegate = self + AppleAuthenticator.sharedInstance.showFrom(viewController: self) + } + + private func googleTapped() { + guard configuration.enableUnifiedAuth else { + GoogleAuthenticator.sharedInstance.loginDelegate = self + GoogleAuthenticator.sharedInstance.showFrom(viewController: self, loginFields: loginFields, for: .login) + return + } + + presentUnifiedGoogleView() + } + + /// Unified "Continue with WordPress.com" prologue button action. + /// + private func continueWithDotCom() { + guard let vc = GetStartedViewController.instantiate(from: .getStarted) else { + WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to GetStartedViewController") + return + } + vc.source = .wpCom + + navigationController?.pushViewController(vc, animated: true) + } + + /// Unified "Enter your existing site address" prologue button action. + /// + private func siteAddressTapped() { + tracker.track(click: .loginWithSiteAddress) + + loginToSelfHostedSite() + } + + private func presentSignUpEmailView() { + guard let toVC = SignupEmailViewController.instantiate(from: .signup) else { + WPAuthenticatorLogError("Failed to navigate to SignupEmailViewController") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + private func presentUnifiedSignupView() { + guard let toVC = UnifiedSignupViewController.instantiate(from: .unifiedSignup) else { + WPAuthenticatorLogError("Failed to navigate to UnifiedSignupViewController") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + private func presentLoginEmailView() { + guard let toVC = LoginEmailViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginEmailVC from LoginPrologueVC") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + // Shows the VC that handles both Google login & signup. + private func presentUnifiedGoogleView() { + guard let toVC = GoogleAuthViewController.instantiate(from: .googleAuth) else { + WPAuthenticatorLogError("Failed to navigate to GoogleAuthViewController from LoginPrologueVC") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + // Shows the VC that handles only Google signup. + private func presentGoogleSignupView() { + guard let toVC = SignupGoogleViewController.instantiate(from: .signup) else { + WPAuthenticatorLogError("Failed to navigate to SignupGoogleViewController from LoginPrologueVC") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + private func presentWPLogin() { + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to LoginWPComViewController") + return + } + + vc.loginFields = self.loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + private func presentUnifiedPassword() { + guard let vc = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("Failed to navigate from LoginPrologueViewController to PasswordViewController") + return + } + + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + + private func createButtonViewController() { + if useStackedButtonsViewController { + let stackedButtonsViewController = NUXStackedButtonsViewController.instance() + self.stackedButtonsViewController = stackedButtonsViewController + stackedButtonsViewController.move(to: self, into: buttonContainerView) + } else { + let buttonViewController = NUXButtonViewController.instance() + self.buttonViewController = buttonViewController + buttonViewController.move(to: self, into: buttonContainerView) + } + view.bringSubviewToFront(buttonContainerView) + } +} + +// MARK: - LoginFacadeDelegate + +extension LoginPrologueViewController { + + // Used by SIWA when logging with with a passwordless, 2FA account. + // + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + configureViewLoading(false) + socialNeedsMultifactorCode(forUserID: userID, andNonceInfo: nonceInfo) + } + +} + +// MARK: - AppleAuthenticatorDelegate + +extension LoginPrologueViewController: AppleAuthenticatorDelegate { + + func showWPComLogin(loginFields: LoginFields) { + self.loginFields = loginFields + + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + presentWPLogin() + return + } + + presentUnifiedPassword() + } + + func showApple2FA(loginFields: LoginFields) { + self.loginFields = loginFields + signInAppleAccount() + } + + func authFailedWithError(message: String) { + displayErrorAlert(message, sourceTag: .loginApple) + } + +} + +// MARK: - GoogleAuthenticatorLoginDelegate + +extension LoginPrologueViewController: GoogleAuthenticatorLoginDelegate { + + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + syncWPComAndPresentEpilogue(credentials: credentials) + } + + func googleNeedsMultifactorCode(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let vc = Login2FAViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to Login2FAViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + func googleExistingUserNeedsConnection(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let vc = LoginWPComViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from Google Login to LoginWPComViewController (password VC)") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields) { + self.loginFields = loginFields + + let socialErrorVC = LoginSocialErrorViewController(title: errorTitle, description: errorDescription) + let socialErrorNav = LoginNavigationController(rootViewController: socialErrorVC) + socialErrorVC.delegate = self + socialErrorVC.loginFields = loginFields + socialErrorVC.modalPresentationStyle = .fullScreen + present(socialErrorNav, animated: true) + } + +} + +// MARK: - Button View Sizing + +private extension LoginPrologueViewController { + + /// Resize the button view based on trait collection. + /// Used only in unified views. + /// + func setButtonViewMargins(forWidth viewWidth: CGFloat) { + + guard configuration.enableUnifiedAuth else { + return + } + + guard traitCollection.horizontalSizeClass == .regular && + traitCollection.verticalSizeClass == .regular else { + buttonViewLeadingConstraint?.constant = defaultButtonViewMargin + buttonViewTrailingConstraint?.constant = defaultButtonViewMargin + return + } + + let marginMultiplier = UIDevice.current.orientation.isLandscape ? + ButtonViewMarginMultipliers.ipadLandscape : + ButtonViewMarginMultipliers.ipadPortrait + + let margin = viewWidth * marginMultiplier + + buttonViewLeadingConstraint?.constant = margin + buttonViewTrailingConstraint?.constant = margin + } + + private enum ButtonViewMarginMultipliers { + static let ipadPortrait: CGFloat = 0.1667 + static let ipadLandscape: CGFloat = 0.25 + } + +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginSelfHostedViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginSelfHostedViewController.swift new file mode 100644 index 000000000000..9bd5db774f2c --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginSelfHostedViewController.swift @@ -0,0 +1,281 @@ +import UIKit +import WordPressShared + +/// Part two of the self-hosted sign in flow. Used by WPiOS and NiOS. +/// A valid site address should be acquired before presenting this view controller. +/// +class LoginSelfHostedViewController: LoginViewController, NUXKeyboardResponder { + @IBOutlet var siteHeaderView: SiteInfoHeaderView! + @IBOutlet var usernameField: WPWalkthroughTextField! + @IBOutlet var passwordField: WPWalkthroughTextField! + @IBOutlet var forgotPasswordButton: WPNUXSecondaryButton! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet var verticalCenterConstraint: NSLayoutConstraint? + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginUsernamePassword + } + } + + override var loginFields: LoginFields { + didSet { + // Clear the username & password (if any) from LoginFields + loginFields.username = "" + loginFields.password = "" + } + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + configureHeader() + localizeControls() + displayLoginMessage("") + configureForAcessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Update special case login fields. + loginFields.meta.userIsDotCom = false + + configureTextFields() + configureSubmitButton(animating: false) + configureViewForEditingIfNeeded() + + setupNavBarIcon() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + WordPressAuthenticator.track(.loginUsernamePasswordFormViewed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + // MARK: - Setup and Configuration + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + usernameField.placeholder = NSLocalizedString("Username", comment: "Username placeholder") + passwordField.placeholder = NSLocalizedString("Password", comment: "Password placeholder") + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + + let forgotPasswordTitle = NSLocalizedString("Lost your password?", comment: "Title of a button. ") + forgotPasswordButton.setTitle(forgotPasswordTitle, for: .normal) + forgotPasswordButton.setTitle(forgotPasswordTitle, for: .highlighted) + forgotPasswordButton.titleLabel?.numberOfLines = 0 + } + + /// Sets up necessary accessibility labels and attributes for the all the UI elements in self. + /// + private func configureForAcessibility() { + usernameField.accessibilityLabel = + NSLocalizedString("Username", comment: "Accessibility label for the username text field in the self-hosted login page.") + passwordField.accessibilityLabel = + NSLocalizedString("Password", comment: "Accessibility label for the password text field in the self-hosted login page.") + + if UIAccessibility.isVoiceOverRunning { + // Remove the placeholder if VoiceOver is running. VoiceOver speaks the label and the + // placeholder together. In this case, both labels and placeholders are the same so it's + // like VoiceOver is reading the same thing twice. + usernameField.placeholder = nil + passwordField.placeholder = nil + } + + forgotPasswordButton.accessibilityTraits = .link + } + + /// Configures the content of the text fields based on what is saved in `loginFields`. + /// + @objc func configureTextFields() { + usernameField.text = loginFields.username + passwordField.text = loginFields.password + passwordField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + usernameField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + } + + /// Configures the appearance and state of the forgot password button. + /// + @objc func configureForgotPasswordButton() { + forgotPasswordButton.isEnabled = enableSubmit(animating: false) + WPStyleGuide.configureTextButton(forgotPasswordButton) + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + submitButton?.isEnabled = ( + !animating && + !loginFields.username.isEmpty && + !loginFields.password.isEmpty + ) + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + usernameField.isEnabled = !loading + passwordField.isEnabled = !loading + + configureSubmitButton(animating: loading) + configureForgotPasswordButton() + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editiing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + usernameField.becomeFirstResponder() + } + } + + /// Configure the site header. + /// + @objc func configureHeader() { + if let siteInfo = loginFields.meta.siteInfo { + configureBlogDetailHeaderView(siteInfo: siteInfo) + } else { + configureSiteAddressHeader() + } + } + + /// Configure the site header to show the BlogDetailsHeaderView + /// + func configureBlogDetailHeaderView(siteInfo: WordPressComSiteInfo) { + let siteAddress = sanitizedSiteAddress(siteAddress: siteInfo.url) + siteHeaderView.title = siteInfo.name + siteHeaderView.subtitle = NSURL.idnDecodedURL(siteAddress) + siteHeaderView.subtitleIsHidden = false + + siteHeaderView.blavatarBorderIsHidden = false + siteHeaderView.downloadBlavatar(at: siteInfo.icon) + } + + /// Configure the site header to show the site address label. + /// + @objc func configureSiteAddressHeader() { + siteHeaderView.title = sanitizedSiteAddress(siteAddress: loginFields.siteAddress) + siteHeaderView.subtitleIsHidden = true + + siteHeaderView.blavatarBorderIsHidden = true + siteHeaderView.blavatarImage = .linkFieldImage + } + + /// Sanitize and format the site address we show to users. + /// + @objc func sanitizedSiteAddress(siteAddress: String) -> String { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: siteAddress) as NSString + if let str = baseSiteUrl.components(separatedBy: "://").last { + return str + } + return siteAddress + } + + // MARK: - Instance Methods + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + validateFormAndLogin() + } + + // MARK: - Actions + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + loginFields.username = usernameField.nonNilTrimmedText() + loginFields.password = passwordField.nonNilTrimmedText() + + configureForgotPasswordButton() + configureSubmitButton(animating: false) + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + validateForm() + } + + @IBAction func handleForgotPasswordButtonTapped(_ sender: UIButton) { + WordPressAuthenticator.openForgotPasswordURL(loginFields) + WordPressAuthenticator.track(.loginForgotPasswordClicked) + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +extension LoginSelfHostedViewController { + + func finishedLogin(withUsername username: String, password: String, xmlrpc: String, options: [AnyHashable: Any]) { + displayLoginMessage("") + + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError() + } + + let wporg = WordPressOrgCredentials(username: username, password: password, xmlrpc: xmlrpc, options: options) + let credentials = AuthenticatorCredentials(wporg: wporg) + delegate.sync(credentials: credentials) { [weak self] in + + NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil) + self?.showLoginEpilogue(for: credentials) + } + } + + func displayLoginMessage(_ message: String) { + configureForgotPasswordButton() + } + + override func displayRemoteError(_ error: Error) { + displayLoginMessage("") + configureViewLoading(false) + let err = error as NSError + if err.code == 403 { + let message = NSLocalizedString("It looks like this username/password isn't associated with this site.", + comment: "An error message shown during log in when the username or password is incorrect.") + displayError(message: message, moveVoiceOverFocus: true) + } else { + displayError(error, sourceTag: sourceTag) + } + } +} + +extension LoginSelfHostedViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == usernameField { + passwordField.becomeFirstResponder() + } else if textField == passwordField { + validateForm() + } + return true + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginSiteAddressViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginSiteAddressViewController.swift new file mode 100644 index 000000000000..f4b0f2c9d795 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginSiteAddressViewController.swift @@ -0,0 +1,351 @@ +import UIKit +import WordPressShared +import WordPressKit +import WordPressUI + +class LoginSiteAddressViewController: LoginViewController, NUXKeyboardResponder { + @IBOutlet weak var siteURLField: WPWalkthroughTextField! + @IBOutlet var siteAddressHelpButton: UIButton! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet var verticalCenterConstraint: NSLayoutConstraint? + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginSiteAddress + } + } + + override var loginFields: LoginFields { + didSet { + // Clear the site url and site info (if any) from LoginFields + loginFields.siteAddress = "" + loginFields.meta.siteInfo = nil + } + } + + // MARK: - URL Validation + + private lazy var urlErrorDebouncer = Debouncer(delay: 2) { [weak self] in + let errorMessage = NSLocalizedString("Please enter a complete website address, like example.com.", comment: "Error message shown when a URL is invalid.") + + self?.displayError(message: errorMessage) + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + localizeControls() + configureForAccessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Update special case login fields. + loginFields.meta.userIsDotCom = false + + configureTextFields() + configureSiteAddressHelpButton() + configureSubmitButton(animating: false) + configureViewForEditingIfNeeded() + + navigationController?.setNavigationBarHidden(false, animated: false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + WordPressAuthenticator.track(.loginURLFormViewed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + // MARK: Setup and Configuration + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + instructionLabel?.text = WordPressAuthenticator.shared.displayStrings.siteLoginInstructions + + siteURLField.placeholder = NSLocalizedString("example.com", comment: "Site Address placeholder") + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + submitButton?.accessibilityIdentifier = "Site Address Next Button" + + let siteAddressHelpTitle = NSLocalizedString("Need help finding your site address?", comment: "A button title.") + siteAddressHelpButton.setTitle(siteAddressHelpTitle, for: .normal) + siteAddressHelpButton.setTitle(siteAddressHelpTitle, for: .highlighted) + siteAddressHelpButton.titleLabel?.numberOfLines = 0 + } + + /// Sets up necessary accessibility labels and attributes for the all the UI elements in self. + /// + private func configureForAccessibility() { + siteURLField.accessibilityLabel = + NSLocalizedString("Site address", comment: "Accessibility label of the site address field shown when adding a self-hosted site.") + } + + /// Configures the content of the text fields based on what is saved in `loginFields`. + /// + @objc func configureTextFields() { + siteURLField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + siteURLField.text = loginFields.siteAddress + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + submitButton?.isEnabled = ( + !animating && canSubmit() + ) + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + siteURLField.isEnabled = !loading + + configureSubmitButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + siteURLField.becomeFirstResponder() + } + } + + private func configureSiteAddressHelpButton() { + WPStyleGuide.configureTextButton(siteAddressHelpButton) + } + + // MARK: - Instance Methods + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + view.endEditing(true) + displayError(message: "") + + // We need to to this here because before this point we need the URL to be pre-validated + // exactly as the user inputs it, and after this point we need the URL to be the base site URL. + // This isn't really great, but it's the only sane solution I could come up with given the current + // architecture of this pod. + loginFields.siteAddress = WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + + configureViewLoading(true) + + let facade = WordPressXMLRPCAPIFacade() + facade.guessXMLRPCURL(forSite: loginFields.siteAddress, success: { [weak self] (url) in + // Success! We now know that we have a valid XML-RPC endpoint. + // At this point, we do NOT know if this is a WP.com site or a self-hosted site. + if let url = url { + self?.loginFields.meta.xmlrpcURL = url as NSURL + } + // Let's try to grab site info in preparation for the next screen. + self?.fetchSiteInfo() + + }, failure: { [weak self] (error) in + guard let error = error, let self = self else { + return + } + WPAuthenticatorLogError(error.localizedDescription) + WordPressAuthenticator.track(.loginFailedToGuessXMLRPC, error: error) + WordPressAuthenticator.track(.loginFailed, error: error) + self.configureViewLoading(false) + + let err = self.originalErrorOrError(error: error as NSError) + + if let xmlrpcValidatorError = err as? WordPressOrgXMLRPCValidatorError { + self.displayError(message: xmlrpcValidatorError.localizedDescription, moveVoiceOverFocus: true) + + } else if (err.domain == NSURLErrorDomain && err.code == NSURLErrorCannotFindHost) || + (err.domain == NSURLErrorDomain && err.code == NSURLErrorNetworkConnectionLost) { + // NSURLErrorNetworkConnectionLost can be returned when an invalid URL is entered. + let msg = NSLocalizedString( + "The site at this address is not a WordPress site. For us to connect to it, the site must use WordPress.", + comment: "Error message shown a URL does not point to an existing site.") + self.displayError(message: msg, moveVoiceOverFocus: true) + + } else { + self.displayError(error, sourceTag: self.sourceTag) + } + }) + } + + @objc func fetchSiteInfo() { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + let service = WordPressComBlogService() + let successBlock: (WordPressComSiteInfo) -> Void = { [weak self] siteInfo in + guard let self = self else { + return + } + self.configureViewLoading(false) + if siteInfo.isWPCom && WordPressAuthenticator.shared.delegate?.allowWPComLogin == false { + // Hey, you have to log out of your existing WP.com account before logging into another one. + self.promptUserToLogoutBeforeConnectingWPComSite() + return + } + self.presentNextControllerIfPossible(siteInfo: siteInfo) + } + service.fetchUnauthenticatedSiteInfoForAddress(for: baseSiteUrl, success: successBlock, failure: { [weak self] _ in + self?.configureViewLoading(false) + guard let self = self else { + return + } + self.presentNextControllerIfPossible(siteInfo: nil) + }) + } + + func presentNextControllerIfPossible(siteInfo: WordPressComSiteInfo?) { + WordPressAuthenticator.shared.delegate?.shouldPresentUsernamePasswordController(for: siteInfo, onCompletion: { (result) in + switch result { + case let .error(error): + self.displayError(message: error.localizedDescription) + case let .presentPasswordController(isSelfHosted): + if isSelfHosted { + self.showSelfHostedUsernamePassword() + } + + self.showWPUsernamePassword() + case .presentEmailController: + // This case is only used for UL&S + break + case .injectViewController: + // This case is only used for UL&S + break + } + }) + } + + @objc func originalErrorOrError(error: NSError) -> NSError { + guard let err = error.userInfo[XMLRPCOriginalErrorKey] as? NSError else { + return error + } + return err + } + + /// Here we will continue with the self-hosted flow. + /// + @objc func showSelfHostedUsernamePassword() { + configureViewLoading(false) + guard let vc = LoginSelfHostedViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginEmailViewController to LoginSelfHostedViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Break away from the self-hosted flow. + /// Display a username / password login screen for WP.com sites. + /// + @objc func showWPUsernamePassword() { + configureViewLoading(false) + + guard let vc = LoginUsernamePasswordViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginSiteAddressViewController to LoginUsernamePasswordViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Whether the form can be submitted. + /// + @objc func canSubmit() -> Bool { + return loginFields.validateSiteForSignin() + } + + @objc private func promptUserToLogoutBeforeConnectingWPComSite() { + let acceptActionTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + let message = NSLocalizedString("Please log out before connecting to a different wordpress.com site", comment: "Message for alert to prompt user to logout before connecting to a different wordpress.com site.") + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alertController.addDefaultActionWithTitle(acceptActionTitle) + present(alertController, animated: true) + } + + // MARK: - URL Validation + + /// Does a local / quick Site Address validation and refreshes the UI with an error + /// if necessary. + /// + /// - Returns: `true` if the Site Address contains a valid URL. `false` otherwise. + /// + private func refreshSiteAddressError(immediate: Bool) { + let showError = !loginFields.siteAddress.isEmpty && !loginFields.validateSiteForSignin() + + if showError { + urlErrorDebouncer.call(immediate: immediate) + } else { + urlErrorDebouncer.cancel() + displayError(message: "") + } + } + + // MARK: - Actions + + @IBAction func handleSubmitForm() { + if canSubmit() { + validateForm() + } + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + validateForm() + } + + @IBAction func handleSiteAddressHelpButtonTapped(_ sender: UIButton) { + let alert = FancyAlertViewController.siteAddressHelpController(loginFields: loginFields, sourceTag: sourceTag) + alert.modalPresentationStyle = .custom + alert.transitioningDelegate = self + present(alert, animated: true, completion: nil) + WordPressAuthenticator.track(.loginURLHelpScreenViewed) + } + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + displayError(message: "") + loginFields.siteAddress = siteURLField.nonNilTrimmedText() + configureSubmitButton(animating: false) + refreshSiteAddressError(immediate: false) + } + + @IBAction func handleEditingDidEnd(_ sender: UITextField) { + refreshSiteAddressError(immediate: true) + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginSocialErrorCell.swift b/WordPressAuthenticator/Sources/Signin/LoginSocialErrorCell.swift new file mode 100644 index 000000000000..0b495f7620d2 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginSocialErrorCell.swift @@ -0,0 +1,94 @@ +import WordPressShared + +open class LoginSocialErrorCell: UITableViewCell { + private let errorTitle: String + private let errorDescription: String + private var errorDescriptionStyled: NSAttributedString? + private let titleLabel: UILabel + private let descriptionLabel: UILabel + private let labelStack: UIStackView + + private var forUnified: Bool = false + + private struct Constants { + static let labelSpacing: CGFloat = 15.0 + static let labelVerticalMargin: CGFloat = 20.0 + static let descriptionMinHeight: CGFloat = 14.0 + } + + @objc public init(title: String, description: String, forUnified: Bool = false) { + errorTitle = title + errorDescription = description + titleLabel = UILabel() + descriptionLabel = UILabel() + labelStack = UIStackView() + self.forUnified = forUnified + + super.init(style: .default, reuseIdentifier: "LoginSocialErrorCell") + + layoutLabels() + } + + public init(title: String, description styledDescription: NSAttributedString) { + errorDescriptionStyled = styledDescription + errorDescription = "" + errorTitle = title + titleLabel = UILabel() + descriptionLabel = UILabel() + labelStack = UIStackView() + + super.init(style: .default, reuseIdentifier: "LoginSocialErrorCell") + + layoutLabels() + } + + required public init?(coder aDecoder: NSCoder) { + errorTitle = aDecoder.value(forKey: "errorTitle") as? String ?? "" + errorDescription = aDecoder.value(forKey: "errorDescription") as? String ?? "" + titleLabel = UILabel() + descriptionLabel = UILabel() + labelStack = UIStackView() + + super.init(coder: aDecoder) + + layoutLabels() + } + + private func layoutLabels() { + contentView.addSubview(labelStack) + labelStack.translatesAutoresizingMaskIntoConstraints = false + labelStack.addArrangedSubview(titleLabel) + labelStack.addArrangedSubview(descriptionLabel) + labelStack.axis = .vertical + labelStack.spacing = Constants.labelSpacing + + let style = WordPressAuthenticator.shared.style + titleLabel.font = WPStyleGuide.fontForTextStyle(.footnote) + titleLabel.textColor = style.instructionColor + descriptionLabel.font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + descriptionLabel.textColor = style.subheadlineColor + descriptionLabel.numberOfLines = 0 + descriptionLabel.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.descriptionMinHeight).isActive = true + + contentView.addConstraints([ + contentView.topAnchor.constraint(equalTo: labelStack.topAnchor, constant: Constants.labelVerticalMargin * -1.0), + contentView.bottomAnchor.constraint(equalTo: labelStack.bottomAnchor, constant: Constants.labelVerticalMargin), + contentView.layoutMarginsGuide.leadingAnchor.constraint(equalTo: labelStack.leadingAnchor), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalTo: labelStack.trailingAnchor) + ]) + + titleLabel.text = errorTitle.localizedUppercase + if let styledDescription = errorDescriptionStyled { + descriptionLabel.attributedText = styledDescription + } else { + descriptionLabel.text = errorDescription + } + + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor + return + } + + backgroundColor = forUnified ? unifiedBackgroundColor : WordPressAuthenticator.shared.style.viewControllerBackgroundColor + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginSocialErrorViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginSocialErrorViewController.swift new file mode 100644 index 000000000000..d301db33b76a --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginSocialErrorViewController.swift @@ -0,0 +1,218 @@ +import Foundation +import Gridicons +import WordPressShared + +@objc +protocol LoginSocialErrorViewControllerDelegate { + func retryWithEmail() + func retryWithAddress() + func retryAsSignup() + func errorDismissed() +} + +/// ViewController for presenting recovery options when social login fails +class LoginSocialErrorViewController: NUXTableViewController { + fileprivate var errorTitle: String + fileprivate var errorDescription: String + @objc weak var delegate: LoginSocialErrorViewControllerDelegate? + + private var forUnified: Bool = false + private var actionButtonTapped: Bool = false + private let unifiedAuthEnabled = WordPressAuthenticator.shared.configuration.enableUnifiedAuth + + fileprivate enum Sections: Int { + case titleAndDescription = 0 + case buttons = 1 + + static var count: Int { + return buttons.rawValue + 1 + } + } + + fileprivate enum Buttons: Int { + case tryEmail = 0 + case tryAddress = 1 + case signup = 2 + + static var count: Int { + return signup.rawValue + 1 + } + } + + /// Create and instance of LoginSocialErrorViewController + /// + /// - Parameters: + /// - title: The title that will be shown on the error VC + /// - description: A brief explination of what failed during social login + @objc init(title: String, description: String) { + errorTitle = title + errorDescription = description + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + errorTitle = aDecoder.value(forKey: "errorTitle") as? String ?? "" + errorDescription = aDecoder.value(forKey: "errorDescription") as? String ?? "" + + super.init(coder: aDecoder) + } + + override func viewDidLoad() { + super.viewDidLoad() + + let unifiedGoogle = unifiedAuthEnabled && loginFields.meta.socialService == .google + let unifiedApple = unifiedAuthEnabled && loginFields.meta.socialService == .apple + forUnified = unifiedGoogle || unifiedApple + + styleNavigationBar(forUnified: forUnified) + styleBackground() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if !actionButtonTapped { + delegate?.errorDismissed() + } + } + + private func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + view.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor + return + } + + view.backgroundColor = forUnified ? unifiedBackgroundColor : WordPressAuthenticator.shared.style.viewControllerBackgroundColor + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard indexPath.section == Sections.buttons.rawValue, + let delegate = delegate else { + return + } + + actionButtonTapped = true + + switch indexPath.row { + case Buttons.tryEmail.rawValue: + delegate.retryWithEmail() + case Buttons.tryAddress.rawValue: + if loginFields.restrictToWPCom { + fallthrough + } else { + delegate.retryWithAddress() + } + case Buttons.signup.rawValue: + fallthrough + default: + delegate.retryAsSignup() + } + } +} + +// MARK: UITableViewDelegate methods + +extension LoginSocialErrorViewController { + private struct RowHeightConstants { + static let estimate: CGFloat = 45.0 + static let automatic: CGFloat = UITableView.automaticDimension + } + + override func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + return RowHeightConstants.estimate + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return RowHeightConstants.automatic + } +} + +// MARK: UITableViewDataSource methods + +extension LoginSocialErrorViewController { + private func numberOfButtonsToShow() -> Int { + + var buttonCount = loginFields.restrictToWPCom ? Buttons.count - 1 : Buttons.count + + // Don't show the Signup Retry if showing unified social flows. + // At this point, we've already tried signup and are past it. + let unifiedGoogle = unifiedAuthEnabled && loginFields.meta.socialService == .google + let unifiedApple = unifiedAuthEnabled && loginFields.meta.socialService == .apple + + if unifiedGoogle || unifiedApple { + buttonCount -= 1 + } + + return buttonCount + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return Sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case Sections.titleAndDescription.rawValue: + return 1 + case Sections.buttons.rawValue: + return numberOfButtonsToShow() + default: + return 0 + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: UITableViewCell + switch indexPath.section { + case Sections.titleAndDescription.rawValue: + cell = titleAndDescriptionCell() + case Sections.buttons.rawValue: + fallthrough + default: + cell = buttonCell(index: indexPath.row) + } + return cell + } + + override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + let footer = UIView() + footer.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor + return footer + } + + override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return 0.5 + } + + private func titleAndDescriptionCell() -> UITableViewCell { + return LoginSocialErrorCell(title: errorTitle, description: errorDescription, forUnified: forUnified) + } + + private func buttonCell(index: Int) -> UITableViewCell { + let cell = UITableViewCell() + let buttonText: String + let buttonIcon: UIImage + switch index { + case Buttons.tryEmail.rawValue: + buttonText = NSLocalizedString("Try with another email", comment: "When social login fails, this button offers to let the user try again with a differen email address") + buttonIcon = .gridicon(.undo) + case Buttons.tryAddress.rawValue: + if loginFields.restrictToWPCom { + fallthrough + } else { + buttonText = NSLocalizedString("Try with the site address", comment: "When social login fails, this button offers to let them try tp login using a URL") + buttonIcon = .gridicon(.domains) + } + case Buttons.signup.rawValue: + fallthrough + default: + buttonText = NSLocalizedString("Sign up", comment: "When social login fails, this button offers to let them signup for a new WordPress.com account") + buttonIcon = .gridicon(.mySites) + } + cell.textLabel?.text = buttonText + cell.textLabel?.textColor = WPStyleGuide.darkGrey() + cell.imageView?.image = buttonIcon.imageWithTintColor(WPStyleGuide.grey()) + return cell + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginUsernamePasswordViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginUsernamePasswordViewController.swift new file mode 100644 index 000000000000..5d26f1082ac7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginUsernamePasswordViewController.swift @@ -0,0 +1,245 @@ +import UIKit +import WordPressShared + +/// Part two of the self-hosted sign in flow. For use by WCiOS only. +/// A valid site address should be acquired before presenting this view controller. +/// +class LoginUsernamePasswordViewController: LoginViewController, NUXKeyboardResponder { + @IBOutlet var siteHeaderView: SiteInfoHeaderView! + @IBOutlet var usernameField: WPWalkthroughTextField! + @IBOutlet var passwordField: WPWalkthroughTextField! + @IBOutlet var forgotPasswordButton: WPNUXSecondaryButton! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet var verticalCenterConstraint: NSLayoutConstraint? + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginWPComUsernamePassword + } + } + + override var loginFields: LoginFields { + didSet { + // Clear the username & password (if any) from LoginFields + loginFields.username = "" + loginFields.password = "" + } + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + configureHeader() + localizeControls() + displayLoginMessage("") + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Update special case login fields. + loginFields.meta.userIsDotCom = true + + configureTextFields() + configureSubmitButton(animating: false) + configureViewForEditingIfNeeded() + + setupNavBarIcon() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + WordPressAuthenticator.track(.loginUsernamePasswordFormViewed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + // MARK: - Setup and Configuration + + /// Assigns localized strings to various UIControl defined in the storyboard. + /// + @objc func localizeControls() { + instructionLabel?.text = WordPressAuthenticator.shared.displayStrings.usernamePasswordInstructions + + usernameField.placeholder = NSLocalizedString("Username", comment: "Username placeholder") + passwordField.placeholder = NSLocalizedString("Password", comment: "Password placeholder") + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + + let forgotPasswordTitle = NSLocalizedString("Lost your password?", comment: "Title of a button. ") + forgotPasswordButton.setTitle(forgotPasswordTitle, for: .normal) + forgotPasswordButton.setTitle(forgotPasswordTitle, for: .highlighted) + forgotPasswordButton.titleLabel?.numberOfLines = 0 + } + + /// Configures the content of the text fields based on what is saved in `loginFields`. + /// + @objc func configureTextFields() { + usernameField.text = loginFields.username + passwordField.text = loginFields.password + passwordField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + usernameField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + } + + /// Configures the appearance and state of the forgot password button. + /// + @objc func configureForgotPasswordButton() { + forgotPasswordButton.isEnabled = enableSubmit(animating: false) + WPStyleGuide.configureTextButton(forgotPasswordButton) + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + submitButton?.isEnabled = ( + !animating && + !loginFields.username.isEmpty && + !loginFields.password.isEmpty + ) + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + usernameField.isEnabled = !loading + passwordField.isEnabled = !loading + + configureSubmitButton(animating: loading) + configureForgotPasswordButton() + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editiing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + usernameField.becomeFirstResponder() + } + } + + /// Configure the site header. + /// + @objc func configureHeader() { + if let siteInfo = loginFields.meta.siteInfo { + configureBlogDetailHeaderView(siteInfo: siteInfo) + } else { + configureSiteAddressHeader() + } + } + + /// Configure the site header to show the BlogDetailsHeaderView + /// + func configureBlogDetailHeaderView(siteInfo: WordPressComSiteInfo) { + let siteAddress = sanitizedSiteAddress(siteAddress: siteInfo.url) + siteHeaderView.title = siteInfo.name + siteHeaderView.subtitle = NSURL.idnDecodedURL(siteAddress) + siteHeaderView.subtitleIsHidden = false + + siteHeaderView.blavatarBorderIsHidden = false + siteHeaderView.downloadBlavatar(at: siteInfo.icon) + } + + /// Configure the site header to show the site address label. + /// + @objc func configureSiteAddressHeader() { + siteHeaderView.title = sanitizedSiteAddress(siteAddress: loginFields.siteAddress) + siteHeaderView.subtitleIsHidden = true + + siteHeaderView.blavatarBorderIsHidden = true + siteHeaderView.blavatarImage = .linkFieldImage + } + + /// Sanitize and format the site address we show to users. + /// + @objc func sanitizedSiteAddress(siteAddress: String) -> String { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: siteAddress) as NSString + if let str = baseSiteUrl.components(separatedBy: "://").last { + return str + } + return siteAddress + } + + // MARK: - Instance Methods + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + validateFormAndLogin() + } + + // MARK: - Actions + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + loginFields.username = usernameField.nonNilTrimmedText() + loginFields.password = passwordField.nonNilTrimmedText() + + configureForgotPasswordButton() + configureSubmitButton(animating: false) + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + validateForm() + } + + @IBAction func handleForgotPasswordButtonTapped(_ sender: UIButton) { + WordPressAuthenticator.openForgotPasswordURL(loginFields) + WordPressAuthenticator.track(.loginForgotPasswordClicked) + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +extension LoginUsernamePasswordViewController { + + func displayLoginMessage(_ message: String) { + configureForgotPasswordButton() + } + + override func displayRemoteError(_ error: Error) { + displayLoginMessage("") + configureViewLoading(false) + let err = error as NSError + if err.code == 403 { + displayError(message: NSLocalizedString("It looks like this username/password isn't associated with this site.", comment: "An error message shown during log in when the username or password is incorrect.")) + } else { + displayError(error, sourceTag: sourceTag) + } + } +} + +extension LoginUsernamePasswordViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == usernameField { + passwordField.becomeFirstResponder() + } else if textField == passwordField { + validateForm() + } + return true + } +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginViewController.swift new file mode 100644 index 000000000000..ca9ca57007ff --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginViewController.swift @@ -0,0 +1,541 @@ +import WordPressShared +import WordPressKit + +/// View Controller for login-specific screens +open class LoginViewController: NUXViewController, LoginFacadeDelegate { + @IBOutlet var instructionLabel: UILabel? + @objc var errorToPresent: Error? + + let tracker = AuthenticatorAnalyticsTracker.shared + + /// Constraints on the table view container. + /// Used to adjust the table width in unified views. + @IBOutlet var tableViewLeadingConstraint: NSLayoutConstraint? + @IBOutlet var tableViewTrailingConstraint: NSLayoutConstraint? + var defaultTableViewMargin: CGFloat = 0 + + lazy var loginFacade: LoginFacade = { + let configuration = WordPressAuthenticator.shared.configuration + let facade = LoginFacade(dotcomClientID: configuration.wpcomClientId, + dotcomSecret: configuration.wpcomSecret, + userAgent: configuration.userAgent) + facade.delegate = self + return facade + }() + + var isJetpackLogin: Bool { + return loginFields.meta.jetpackLogin + } + + private var isSignUp: Bool { + return loginFields.meta.emailMagicLinkSource == .signup + } + + var authenticationDelegate: WordPressAuthenticatorDelegate { + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError() + } + + return delegate + } + + open override var preferredStatusBarStyle: UIStatusBarStyle { + // Set to the old style as the default. + // Each VC in the unified flows needs to override this to use the unified style. + return WordPressAuthenticator.shared.style.statusBarStyle + } + + // MARK: Lifecycle Methods + + override open func viewDidLoad() { + super.viewDidLoad() + + displayError(message: "") + styleNavigationBar(forUnified: true) + styleBackground() + styleInstructions() + + if let error = errorToPresent { + displayRemoteError(error) + errorToPresent = nil + } + } + + override open func viewWillDisappear(_ animated: Bool) { + if isBeingDismissedInAnyWay { + tracker.track(click: .dismiss) + } + } + + func didChangePreferredContentSize() { + styleInstructions() + } + + // MARK: - Setup and Configuration + + /// Styles the view's background color. Defaults to WPStyleGuide.lightGrey() + /// + func styleBackground() { + view.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor + } + + /// Configures instruction label font + /// + func styleInstructions() { + instructionLabel?.font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + instructionLabel?.adjustsFontForContentSizeCategory = true + instructionLabel?.textColor = WordPressAuthenticator.shared.style.instructionColor + } + + func configureViewLoading(_ loading: Bool) { + configureSubmitButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + /// Sets the text of the error label. + /// + /// - Parameter message: The message to display in the `errorLabel`. If empty, the `errorLabel` + /// will be hidden. + /// - Parameter moveVoiceOverFocus: If `true`, moves the VoiceOver focus to the `errorLabel`. + /// You will want to set this to `true` if the error was caused after pressing a button + /// (e.g. Next button). + func displayError(message: String, moveVoiceOverFocus: Bool = false) { + guard message.count > 0 else { + errorLabel?.isHidden = true + return + } + + tracker.track(failure: message) + + errorLabel?.isHidden = false + errorLabel?.text = message + errorToPresent = nil + + if moveVoiceOverFocus, let errorLabel = errorLabel { + UIAccessibility.post(notification: .layoutChanged, argument: errorLabel) + } + } + + private func mustShowLoginEpilogue() -> Bool { + return isSignUp == false && authenticationDelegate.shouldPresentLoginEpilogue(isJetpackLogin: isJetpackLogin) + } + + private func mustShowSignupEpilogue() -> Bool { + return isSignUp && authenticationDelegate.shouldPresentSignupEpilogue() + } + + // MARK: - Epilogue + + func showSignupEpilogue(for credentials: AuthenticatorCredentials) { + guard let navigationController = navigationController else { + fatalError() + } + + authenticationDelegate.presentSignupEpilogue( + in: navigationController, + for: credentials, + socialUser: loginFields.meta.socialUser + ) + } + + func showLoginEpilogue(for credentials: AuthenticatorCredentials) { + guard let navigationController = navigationController else { + fatalError() + } + + authenticationDelegate.presentLoginEpilogue(in: navigationController, + for: credentials, + source: WordPressAuthenticator.shared.signInSource) { [weak self] in + self?.dismissBlock?(false) + } + } + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with login. + /// + func validateFormAndLogin() { + view.endEditing(true) + displayError(message: "") + + // Is everything filled out? + if !loginFields.validateFieldsPopulatedForSignin() { + let errorMsg = LocalizedText.missingInfoError + displayError(message: errorMsg) + + return + } + + configureViewLoading(true) + + loginFacade.signIn(with: loginFields) + } + + // MARK: SigninWPComSyncHandler methods + dynamic open func finishedLogin(withAuthToken authToken: String, requiredMultifactorCode: Bool) { + let wpcom = WordPressComCredentials(authToken: authToken, isJetpackLogin: isJetpackLogin, multifactor: requiredMultifactorCode, siteURL: loginFields.siteAddress) + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + syncWPComAndPresentEpilogue(credentials: credentials) + + linkSocialServiceIfNeeded(loginFields: loginFields, wpcomAuthToken: authToken) + } + + func configureStatusLabel(_ message: String) { + // this is now a no-op, unless status labels return + } + + /// Overridden here to direct these errors to the login screen's error label + dynamic open func displayRemoteError(_ error: Error) { + configureViewLoading(false) + let err = error as NSError + guard err.code != 403 else { + let message = LocalizedText.loginError + displayError(message: message) + return + } + + displayError(err, sourceTag: sourceTag) + } + + open func needsMultifactorCode() { + displayError(message: "") + configureViewLoading(false) + + if tracker.shouldUseLegacyTracker() { + WordPressAuthenticator.track(.twoFactorCodeRequested) + } + + let unifiedAuthEnabled = WordPressAuthenticator.shared.configuration.enableUnifiedAuth + let unifiedGoogle = unifiedAuthEnabled && loginFields.meta.socialService == .google + let unifiedApple = unifiedAuthEnabled && loginFields.meta.socialService == .apple + let unifiedSiteAddress = unifiedAuthEnabled && !loginFields.siteAddress.isEmpty + let unifiedWordPress = unifiedAuthEnabled && loginFields.meta.userIsDotCom + + guard unifiedGoogle || unifiedApple || unifiedSiteAddress || unifiedWordPress else { + presentLogin2FA() + return + } + + // Make sure we don't provide any old nonce information when we are required to present only the multi-factor code option. + loginFields.nonceInfo = nil + loginFields.nonceUserID = 0 + + presentUnified2FA() + } + + private enum LocalizedText { + static let loginError = NSLocalizedString("Whoops, something went wrong and we couldn't log you in. Please try again!", comment: "An error message shown when a wpcom user provides the wrong password.") + static let missingInfoError = NSLocalizedString("Please fill out all the fields", comment: "A short prompt asking the user to properly fill out all login fields.") + static let gettingAccountInfo = NSLocalizedString("Getting account information", comment: "Alerts the user that wpcom account information is being retrieved.") + } +} + +// MARK: - View FLow + +extension LoginViewController { + func presentEpilogue(credentials: AuthenticatorCredentials) { + if mustShowSignupEpilogue() { + showSignupEpilogue(for: credentials) + } else if mustShowLoginEpilogue() { + showLoginEpilogue(for: credentials) + } else { + dismiss() + } + } +} + +// MARK: - Sync Helpers + +extension LoginViewController { + + /// Signals the Main App to synchronize the specified WordPress.com account. On completion, the epilogue will be pushed (if needed). + /// + func syncWPComAndPresentEpilogue( + credentials: AuthenticatorCredentials, + completion: (() -> Void)? = nil) { + + configureStatusLabel(LocalizedText.gettingAccountInfo) + + syncWPCom(credentials: credentials) { [weak self] in + guard let self = self else { + return + } + + completion?() + + self.presentEpilogue(credentials: credentials) + self.configureStatusLabel("") + self.configureViewLoading(false) + self.trackSignIn(credentials: credentials) + } + } + + /// Signals the Main App to synchronize the specified WordPress.com account. + /// + func syncWPCom(credentials: AuthenticatorCredentials, completion: (() -> Void)? = nil) { + authenticationDelegate.sync(credentials: credentials) { + completion?() + } + } + + /// Tracks the SignIn Event + /// + func trackSignIn(credentials: AuthenticatorCredentials) { + var properties = [String: String]() + + if let wpcom = credentials.wpcom { + properties = [ + "multifactor": wpcom.multifactor.description, + "dotcom_user": true.description + ] + } + + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + WordPressAuthenticator.track(.signedIn, properties: properties) + tracker.track(step: .success) + } + + /// Links the current WordPress Account to a Social Service (if possible!!). + /// + func linkSocialServiceIfNeeded(loginFields: LoginFields, wpcomAuthToken: String) { + guard let serviceName = loginFields.meta.socialService, let serviceToken = loginFields.meta.socialServiceIDToken else { + return + } + + linkSocialService(serviceName: serviceName, + serviceToken: serviceToken, + wpcomAuthToken: wpcomAuthToken, + appleConnectParameters: loginFields.parametersForSignInWithApple) + } + + /// Links the current WordPress Account to a Social Service. + /// + func linkSocialService(serviceName: SocialServiceName, + serviceToken: String, + wpcomAuthToken: String, + appleConnectParameters: [String: AnyObject]? = nil) { + let service = WordPressComAccountService() + service.connect(wpcomAuthToken: wpcomAuthToken, + serviceName: serviceName, + serviceToken: serviceToken, + connectParameters: appleConnectParameters, + success: { + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + let source = appleConnectParameters != nil ? "apple" : "google" + WordPressAuthenticator.track(.signedIn, properties: ["source": source]) + + if AuthenticatorAnalyticsTracker.shared.shouldUseLegacyTracker() { + WordPressAuthenticator.track(.loginSocialConnectSuccess) + WordPressAuthenticator.track(.loginSocialSuccess) + } + }, failure: { error in + WPAuthenticatorLogError("Social Link Error: \(error)") + WordPressAuthenticator.track(.loginSocialConnectFailure, error: error) + // We're opting to let this call fail silently. + // Our user has already successfully authenticated and can use the app -- + // connecting the social service isn't critical. There's little to + // be gained by displaying an error that can not currently be resolved + // in the app and doing so might tarnish an otherwise satisfying login + // experience. + // If/when we add support for manually connecting/disconnecting services + // we can revisit. + }) + } +} + +// MARK: - Handle View Changes +// +extension LoginViewController { + + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // Update Dynamic Type + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + + // Update Table View size + setTableViewMargins(forWidth: view.frame.width) + } + + open override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + setTableViewMargins(forWidth: size.width) + } + + /// Resize the table view based on trait collection. + /// Used only in unified views. + /// + func setTableViewMargins(forWidth viewWidth: CGFloat) { + guard let tableViewLeadingConstraint = tableViewLeadingConstraint, + let tableViewTrailingConstraint = tableViewTrailingConstraint else { + return + } + + guard traitCollection.horizontalSizeClass == .regular && + traitCollection.verticalSizeClass == .regular else { + tableViewLeadingConstraint.constant = defaultTableViewMargin + tableViewTrailingConstraint.constant = defaultTableViewMargin + return + } + + let marginMultiplier = UIDevice.current.orientation.isLandscape ? + TableViewMarginMultipliers.ipadLandscape : + TableViewMarginMultipliers.ipadPortrait + + let margin = viewWidth * marginMultiplier + + tableViewLeadingConstraint.constant = margin + tableViewTrailingConstraint.constant = margin + } + + private enum TableViewMarginMultipliers { + static let ipadPortrait: CGFloat = 0.1667 + static let ipadLandscape: CGFloat = 0.25 + } + +} + +// MARK: - Social Sign In Handling + +extension LoginViewController { + + func removeGoogleWaitingView() { + // Remove the Waiting for Google view so it doesn't reappear when backing through the navigation stack. + navigationController?.viewControllers.removeAll(where: { $0 is GoogleAuthViewController }) + } + + func signInAppleAccount() { + guard let token = loginFields.meta.socialServiceIDToken else { + WordPressAuthenticator.track(.loginSocialButtonFailure, properties: ["source": SocialServiceName.apple.rawValue]) + configureViewLoading(false) + return + } + + loginFacade.loginToWordPressDotCom(withSocialIDToken: token, service: SocialServiceName.apple.rawValue) + } + + // Used by SIWA when logging with with a passwordless, 2FA account. + // + func socialNeedsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + loginFields.nonceInfo = nonceInfo + loginFields.nonceUserID = userID + + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + presentLogin2FA() + return + } + + presentUnified2FA() + } + + private func presentLogin2FA() { + var properties = [AnyHashable: Any]() + if let service = loginFields.meta.socialService?.rawValue { + properties["source"] = service + } + + if tracker.shouldUseLegacyTracker() { + WordPressAuthenticator.track(.loginSocial2faNeeded, properties: properties) + } + + guard let vc = Login2FAViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to Login2FAViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + private func presentUnified2FA() { + + guard let vc = TwoFAViewController.instantiate(from: .twoFA) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to TwoFAViewController") + return + } + + vc.dismissBlock = dismissBlock + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + +} + +// MARK: - LoginSocialError delegate methods + +extension LoginViewController: LoginSocialErrorViewControllerDelegate { + + func retryWithEmail() { + loginFields.username = "" + cleanupAfterSocialErrors() + navigationController?.popToRootViewController(animated: true) + } + + func retryWithAddress() { + cleanupAfterSocialErrors() + loginToSelfHostedSite() + } + + func retryAsSignup() { + cleanupAfterSocialErrors() + + if let controller = SignupEmailViewController.instantiate(from: .signup) { + controller.loginFields = loginFields + navigationController?.pushViewController(controller, animated: true) + } + } + + func errorDismissed() { + loginFields.username = "" + navigationController?.popToRootViewController(animated: true) + } + + private func cleanupAfterSocialErrors() { + dismiss(animated: true) {} + } + + /// Displays the self-hosted login form. + /// + @objc func loginToSelfHostedSite() { + guard WordPressAuthenticator.shared.configuration.enableUnifiedAuth else { + presentSelfHostedView() + return + } + + presentUnifiedSiteAddressView() + } + + /// Navigates to the unified site address login flow. + /// + func presentUnifiedSiteAddressView() { + guard let vc = SiteAddressViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to SiteAddressViewController") + return + } + + navigationController?.pushViewController(vc, animated: true) + } + + /// Navigates to the old self-hosted login flow. + /// + func presentSelfHostedView() { + guard let vc = LoginSiteAddressViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to LoginSiteAddressViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + +} diff --git a/WordPressAuthenticator/Sources/Signin/LoginWPComViewController.swift b/WordPressAuthenticator/Sources/Signin/LoginWPComViewController.swift new file mode 100644 index 000000000000..1ab00794869e --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/LoginWPComViewController.swift @@ -0,0 +1,258 @@ +import UIKit +import WordPressShared +import WordPressKit + +/// Provides a form and functionality for signing a user in to WordPress.com +/// +class LoginWPComViewController: LoginViewController, NUXKeyboardResponder { + @IBOutlet weak var passwordField: WPWalkthroughTextField? + @IBOutlet weak var forgotPasswordButton: UIButton? + @IBOutlet weak var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet weak var verticalCenterConstraint: NSLayoutConstraint? + @IBOutlet var emailIcon: UIImageView? + @IBOutlet var emailLabel: UITextField? + @IBOutlet var emailStackView: UIStackView? + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginWPComPassword + } + } + + override var loginFields: LoginFields { + didSet { + // Clear the password (if any) from LoginFields. + loginFields.password = "" + } + } + + // MARK: - Lifecycle Methods + + override func viewDidLoad() { + super.viewDidLoad() + + localizeControls() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Update special case login fields. + loginFields.meta.userIsDotCom = true + + configureTextFields() + configureEmailIcon() + configureForgotPasswordButton() + configureSubmitButton(animating: false) + configureViewForEditingIfNeeded() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + passwordField?.becomeFirstResponder() + WordPressAuthenticator.track(.loginPasswordFormViewed) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + + if isMovingFromParent { + // There was a bug that was causing iOS's update password prompt to come up + // when this VC was being dismissed pressing the "< Back" button. The following + // line ensures that such prompt doesn't come up anymore. + // + // More information can be found in the PR where this workaround is introduced: + // https://git.io/JUkak + // + passwordField?.text = "" + } + } + + // MARK: Setup and Configuration + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + submitButton?.isEnabled = enableSubmit(animating: animating) + } + + override func enableSubmit(animating: Bool) -> Bool { + return !animating && + !loginFields.username.isEmpty && + !loginFields.password.isEmpty + } + + /// Configure the view's loading state. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + passwordField?.isEnabled = !loading + + configureSubmitButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editiing state should be assumed. + // Check the helper to determine whether an editiing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + passwordField?.becomeFirstResponder() + } + } + + @objc func configureTextFields() { + passwordField?.text = loginFields.password + passwordField?.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + emailLabel?.text = loginFields.username + emailLabel?.textColor = WordPressAuthenticator.shared.style.subheadlineColor + } + + func configureEmailIcon() { + guard let image = emailIcon?.image else { + return + } + emailIcon?.image = image.imageWithTintColor(WordPressAuthenticator.shared.style.subheadlineColor) + } + + private func configureForgotPasswordButton() { + guard let forgotPasswordButton = forgotPasswordButton else { + return + } + WPStyleGuide.configureTextButton(forgotPasswordButton) + } + + @objc func localizeControls() { + + instructionLabel?.text = { + guard let service = loginFields.meta.socialService else { + return NSLocalizedString("Enter the password for your WordPress.com account.", comment: "Instructional text shown when requesting the user's password for login.") + } + + if service == SocialServiceName.google { + return NSLocalizedString("To proceed with this Google account, please first log in with your WordPress.com password. This will only be asked once.", comment: "") + } + + return NSLocalizedString( + "Please enter the password for your WordPress.com account to log in with your Apple ID.", + comment: "Instructional text shown when requesting the user's password for a login initiated via Sign In with Apple" + ) + }() + + passwordField?.placeholder = NSLocalizedString("Password", comment: "Password placeholder") + passwordField?.accessibilityIdentifier = "Password" + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + submitButton?.accessibilityIdentifier = "Password Next Button" + + let forgotPasswordTitle = NSLocalizedString("Lost your password?", comment: "Title of a button. ") + forgotPasswordButton?.setTitle(forgotPasswordTitle, for: .normal) + forgotPasswordButton?.setTitle(forgotPasswordTitle, for: .highlighted) + forgotPasswordButton?.titleLabel?.numberOfLines = 0 + } + + // MARK: - Instance Methods + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + validateFormAndLogin() + } + + // MARK: - Actions + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + switch sender { + case passwordField: + loginFields.password = sender.nonNilTrimmedText() + case emailLabel: + // The email can only be changed via a password manager. + // In this case, don't update username for social accounts. + // This prevents inadvertent account linking. + // Ref: https://git.io/JJSUM + if loginFields.meta.socialService != nil { + emailLabel?.text = loginFields.username + } else { + loginFields.username = sender.nonNilTrimmedText() + } + default: + break + } + + configureSubmitButton(animating: false) + } + + @IBAction func handleSubmitButtonTapped(_ sender: UIButton) { + validateForm() + } + + @IBAction func handleForgotPasswordButtonTapped(_ sender: UIButton) { + WordPressAuthenticator.openForgotPasswordURL(loginFields) + WordPressAuthenticator.track(.loginForgotPasswordClicked) + } + + override func displayRemoteError(_ error: Error) { + configureViewLoading(false) + + if (error as? WordPressComOAuthError)?.authenticationFailureKind == .invalidRequest { + let message = NSLocalizedString("It seems like you've entered an incorrect password. Want to give it another try?", comment: "An error message shown when a wpcom user provides the wrong password.") + displayError(message: message) + } else { + super.displayRemoteError(error) + } + } + + // MARK: - Dynamic type + + override func didChangePreferredContentSize() { + super.didChangePreferredContentSize() + emailLabel?.font = WPStyleGuide.fontForTextStyle(.body) + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } + + // MARK: Keyboard Events + + @objc func signinFormVerticalOffset() -> CGFloat { + // the stackview-based layout shifts fine with this adjustment + return 0 + } +} + +extension LoginWPComViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if enableSubmit(animating: false) { + validateForm() + } + return true + } +} + +extension LoginWPComViewController { + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory { + didChangePreferredContentSize() + } + } +} diff --git a/WordPressAuthenticator/Sources/Signin/SigninEditingState.swift b/WordPressAuthenticator/Sources/Signin/SigninEditingState.swift new file mode 100644 index 000000000000..e2ecf4f4aa7a --- /dev/null +++ b/WordPressAuthenticator/Sources/Signin/SigninEditingState.swift @@ -0,0 +1,6 @@ +import Foundation + +open class SigninEditingState { + public static var signinEditingStateActive = false + public static var signinLastKeyboardHeightDelta: CGFloat = 0 +} diff --git a/WordPressAuthenticator/Sources/Signup/Signup.storyboard b/WordPressAuthenticator/Sources/Signup/Signup.storyboard new file mode 100644 index 000000000000..94c12288ea9f --- /dev/null +++ b/WordPressAuthenticator/Sources/Signup/Signup.storyboard @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Signup/SignupEmailViewController.swift b/WordPressAuthenticator/Sources/Signup/SignupEmailViewController.swift new file mode 100644 index 000000000000..c308a0160aab --- /dev/null +++ b/WordPressAuthenticator/Sources/Signup/SignupEmailViewController.swift @@ -0,0 +1,241 @@ +import UIKit +import WordPressShared +import WordPressKit + +class SignupEmailViewController: LoginViewController, NUXKeyboardResponder { + + // MARK: - NUXKeyboardResponder Properties + + @IBOutlet weak var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet weak var verticalCenterConstraint: NSLayoutConstraint? + + // MARK: - Properties + + @IBOutlet weak var emailField: LoginTextField! + + override var sourceTag: WordPressSupportSourceTag { + get { + return .wpComSignupEmail + } + } + + private enum ErrorMessage: String { + case invalidEmail = "invalid_email" + case availabilityCheckFail = "availability_check_fail" + case emailUnavailable = "email_unavailable" + case magicLinkRequestFail = "magic_link_request_fail" + + func description() -> String { + switch self { + case .invalidEmail: + return NSLocalizedString("Please enter a valid email address.", comment: "Error message displayed when the user attempts use an invalid email address.") + case .availabilityCheckFail: + return NSLocalizedString("Unable to verify the email address. Please try again later.", comment: "Error message displayed when an error occurred checking for email availability.") + case .emailUnavailable: + return NSLocalizedString("Sorry, that email address is already being used!", comment: "Error message displayed when the entered email is not available.") + case .magicLinkRequestFail: + return NSLocalizedString("We were unable to send you an email at this time. Please try again later.", comment: "Error message displayed when an error occurred sending the magic link email.") + } + } + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + localizeControls() + WordPressAuthenticator.track(.createAccountInitiated) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + configureViewForEditingIfNeeded() + + // If email address already exists, pre-populate it. + emailField.text = loginFields.emailAddress + + configureSubmitButton(animating: false) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + private func localizeControls() { + instructionLabel?.text = NSLocalizedString("To create your new WordPress.com account, please enter your email address.", comment: "Text instructing the user to enter their email address.") + + emailField.placeholder = NSLocalizedString("Email address", comment: "Placeholder for a textfield. The user may enter their email address.") + emailField.accessibilityIdentifier = "Signup Email Address" + emailField.contentInsets = WPStyleGuide.edgeInsetForLoginTextFields() + + let submitButtonTitle = NSLocalizedString("Next", comment: "Title of a button. The text should be capitalized.").localizedCapitalized + submitButton?.setTitle(submitButtonTitle, for: .normal) + submitButton?.setTitle(submitButtonTitle, for: .highlighted) + submitButton?.accessibilityIdentifier = "Signup Email Next Button" + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + private func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + emailField.becomeFirstResponder() + } + } + + override func enableSubmit(animating: Bool) -> Bool { + return !animating && validEmail() + } + + // MARK: - Keyboard Notifications + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } + + // MARK: - Email Validation + + private func validateForm() { + + // Hide the error label. + displayError(message: "") + + // If the email address is invalid, display appropriate message. + if !validEmail() { + displayError(message: ErrorMessage.invalidEmail.description()) + configureSubmitButton(animating: false) + return + } + + checkEmailAvailability { available in + if available { + self.loginFields.username = self.loginFields.emailAddress + self.loginFields.meta.emailMagicLinkSource = .signup + self.requestAuthenticationLink() + } + self.configureSubmitButton(animating: false) + } + } + + private func validEmail() -> Bool { + return EmailFormatValidator.validate(string: loginFields.emailAddress) + } + + // MARK: - Email Availability + + private func checkEmailAvailability(completion: @escaping (Bool) -> Void) { + + let remote = AccountServiceRemoteREST( + wordPressComRestApi: WordPressComRestApi(baseURL: WordPressAuthenticator.shared.configuration.wpcomAPIBaseURL)) + + remote.isEmailAvailable(loginFields.emailAddress, success: { [weak self] available in + if !available { + defer { + WordPressAuthenticator.track(.signupEmailToLogin) + } + // If the user has already signed up redirect to the Login flow + guard let vc = LoginEmailViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginEmailViewController from SignupEmailViewController") + return + } + + guard let self = self else { + return + } + + vc.loginFields.restrictToWPCom = true + vc.loginFields.username = self.loginFields.emailAddress + + self.navigationController?.pushViewController(vc, animated: true) + } + completion(available) + }, failure: { error in + guard let error = error else { + self.displayError(message: ErrorMessage.availabilityCheckFail.description()) + completion(false) + return + } + + WPAuthenticatorLogError("Error checking email availability: \(error.localizedDescription)") + + switch error { + case AccountServiceRemoteError.emailAddressInvalid: + self.displayError(message: error.localizedDescription) + completion(false) + default: + self.displayError(message: ErrorMessage.availabilityCheckFail.description()) + completion(false) + } + }) + } + + // MARK: - Send email + + /// Makes the call to request a magic signup link be emailed to the user. + /// + private func requestAuthenticationLink() { + + configureSubmitButton(animating: true) + + let service = WordPressComAccountService() + service.requestSignupLink(for: loginFields.username, + success: { [weak self] in + self?.didRequestSignupLink() + self?.configureSubmitButton(animating: false) + + }, failure: { [weak self] (_: Error) in + WPAuthenticatorLogError("Request for signup link email failed.") + WordPressAuthenticator.track(.signupMagicLinkFailed) + self?.displayError(message: ErrorMessage.magicLinkRequestFail.description()) + self?.configureSubmitButton(animating: false) + }) + } + + private func didRequestSignupLink() { + WordPressAuthenticator.track(.signupMagicLinkRequested) + + guard let vc = NUXLinkMailViewController.instantiate(from: .emailMagicLink) else { + WPAuthenticatorLogError("Failed to navigate to NUXLinkMailViewController") + return + } + + vc.loginFields = loginFields + vc.loginFields.restrictToWPCom = true + + navigationController?.pushViewController(vc, animated: true) + } + + // MARK: - Action Handling + + @IBAction func handleSubmit() { + displayError(message: "") + configureSubmitButton(animating: true) + validateForm() + } + + @IBAction func handleTextFieldDidChange(_ sender: UITextField) { + loginFields.emailAddress = emailField.nonNilTrimmedText() + configureSubmitButton(animating: false) + } + + // MARK: - Misc + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + // Dispose of any resources that can be recreated. + } + +} diff --git a/WordPressAuthenticator/Sources/Signup/SignupGoogleViewController.swift b/WordPressAuthenticator/Sources/Signup/SignupGoogleViewController.swift new file mode 100644 index 000000000000..25e3570caf91 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signup/SignupGoogleViewController.swift @@ -0,0 +1,80 @@ +/// View controller that handles the google signup flow +/// +class SignupGoogleViewController: LoginViewController { + + // MARK: - Properties + + private var hasShownGoogle = false + @IBOutlet var titleLabel: UILabel? + + override var sourceTag: WordPressSupportSourceTag { + get { + return .wpComSignupWaitingForGoogle + } + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + titleLabel?.text = LocalizedText.waitingForGoogle + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showGoogleScreenIfNeeded() + } + +} + +// MARK: - Private Methods + +private extension SignupGoogleViewController { + + func showGoogleScreenIfNeeded() { + guard !hasShownGoogle else { + return + } + + // Flag this as a social sign in. + loginFields.meta.socialService = .google + + GoogleAuthenticator.sharedInstance.signupDelegate = self + GoogleAuthenticator.sharedInstance.showFrom(viewController: self, loginFields: loginFields, for: .signup) + + hasShownGoogle = true + } + + enum LocalizedText { + static let waitingForGoogle = NSLocalizedString("Waiting for Google to complete…", comment: "Message shown on screen while waiting for Google to finish its signup process.") + static let signupFailed = NSLocalizedString("Google sign up failed.", comment: "Message shown on screen after the Google sign up process failed.") + } + +} + +// MARK: - GoogleAuthenticatorSignupDelegate + +extension SignupGoogleViewController: GoogleAuthenticatorSignupDelegate { + + func googleFinishedSignup(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + showSignupEpilogue(for: credentials) + } + + func googleLoggedInInstead(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + showLoginEpilogue(for: credentials) + } + + func googleSignupFailed(error: Error, loginFields: LoginFields) { + self.loginFields = loginFields + titleLabel?.textColor = WPStyleGuide.errorRed() + titleLabel?.text = LocalizedText.signupFailed + displayError(error, sourceTag: .wpComSignup) + } + + func googleSignupCancelled() { + navigationController?.popViewController(animated: true) + } + +} diff --git a/WordPressAuthenticator/Sources/Signup/SignupNavigationController.swift b/WordPressAuthenticator/Sources/Signup/SignupNavigationController.swift new file mode 100644 index 000000000000..2bdab164f574 --- /dev/null +++ b/WordPressAuthenticator/Sources/Signup/SignupNavigationController.swift @@ -0,0 +1,8 @@ +import UIKit +import WordPressUI + +class SignupNavigationController: RotationAwareNavigationViewController { + override func viewDidLoad() { + super.viewDidLoad() + } +} diff --git a/WordPressAuthenticator/Sources/UI/CircularImageView.swift b/WordPressAuthenticator/Sources/UI/CircularImageView.swift new file mode 100644 index 000000000000..584c43f14567 --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/CircularImageView.swift @@ -0,0 +1,22 @@ +import Foundation + +/// UIImageView with a circular shape. +/// +class CircularImageView: UIImageView { + + override var frame: CGRect { + didSet { + refreshRadius() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + refreshRadius() + } + + private func refreshRadius() { + layer.cornerRadius = frame.width * 0.5 + layer.masksToBounds = true + } +} diff --git a/WordPressAuthenticator/Sources/UI/LoginTextField.swift b/WordPressAuthenticator/Sources/UI/LoginTextField.swift new file mode 100644 index 000000000000..b198594705fc --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/LoginTextField.swift @@ -0,0 +1,77 @@ +import UIKit +import WordPressShared + +open class LoginTextField: WPWalkthroughTextField { + + /// Make a Swift-only property communicate a color to the + /// Objective-C only class, WPWalkthroughTextField. + /// + open override var secureTextEntryImageColor: UIColor! { + set { + // no-op. Usually set in Interface Builder. + } + get { + return WordPressAuthenticator.shared.style.secondaryNormalBorderColor + } + } + + open override func awakeFromNib() { + super.awakeFromNib() + backgroundColor = WordPressAuthenticator.shared.style.textFieldBackgroundColor + } + + override open func draw(_ rect: CGRect) { + if showTopLineSeparator { + guard let context = UIGraphicsGetCurrentContext() else { + return + } + + drawTopLine(rect: rect, context: context) + drawBottomLine(rect: rect, context: context) + } + } + + override open var placeholder: String? { + didSet { + guard let placeholder = placeholder, + let font = font else { + return + } + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: WordPressAuthenticator.shared.style.placeholderColor, + .font: font + ] + attributedPlaceholder = NSAttributedString(string: placeholder, attributes: attributes) + } + } + + override open var leftViewImage: UIImage! { + set { + let newImage = newValue.imageWithTintColor(WordPressAuthenticator.shared.style.placeholderColor) + super.leftViewImage = newImage + } + get { + return super.leftViewImage + } + } + + private func drawTopLine(rect: CGRect, context: CGContext) { + drawBorderLine(from: CGPoint(x: rect.minX, y: rect.minY), to: CGPoint(x: rect.maxX, y: rect.minY), context: context) + } + + private func drawBottomLine(rect: CGRect, context: CGContext) { + drawBorderLine(from: CGPoint(x: rect.minX, y: rect.maxY), to: CGPoint(x: rect.maxX, y: rect.maxY), context: context) + } + + private func drawBorderLine(from startPoint: CGPoint, to endPoint: CGPoint, context: CGContext) { + let path = UIBezierPath() + + path.move(to: startPoint) + path.addLine(to: endPoint) + path.lineWidth = UIScreen.main.scale / 2.0 + context.addPath(path.cgPath) + context.setStrokeColor(WordPressAuthenticator.shared.style.secondaryNormalBorderColor.cgColor) + context.strokePath() + } +} diff --git a/WordPressAuthenticator/Sources/UI/SearchTableViewCell.swift b/WordPressAuthenticator/Sources/UI/SearchTableViewCell.swift new file mode 100644 index 000000000000..bfb1c557a309 --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/SearchTableViewCell.swift @@ -0,0 +1,160 @@ +import UIKit +import WordPressShared + +// MARK: - SearchTableViewCellDelegate +// +public protocol SearchTableViewCellDelegate: AnyObject { + func startSearch(for: String) +} + +// MARK: - SearchTableViewCell +// +open class SearchTableViewCell: UITableViewCell { + + /// UITableView's Reuse Identifier + /// + public static let reuseIdentifier = "SearchTableViewCell" + + /// Search 'UITextField's reference! + /// + @IBOutlet public var textField: LoginTextField! + + /// UITextField's listener + /// + open weak var delegate: SearchTableViewCellDelegate? + + /// If `true` the search delegate callback is called as the text field is edited. + /// This class does not implement any Debouncer or assume a minimum character count because + /// each search is different. + /// + open var liveSearch: Bool = false + + /// If `true` then the user can type in spaces regularly. If `false` the whitespaces will be + /// stripped before they're entered into the field. + /// + open var allowSpaces: Bool = true + + /// Search UITextField's placeholder + /// + open var placeholder: String? { + get { + return textField.placeholder + } + set { + textField.placeholder = newValue + } + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override open func awakeFromNib() { + super.awakeFromNib() + textField.delegate = self + textField.returnKeyType = .search + textField.contentInsets = Constants.textInsetsWithIcon + textField.accessibilityIdentifier = "Search field" + textField.leftViewImage = textField?.leftViewImage?.imageWithTintColor(WordPressAuthenticator.shared.style.placeholderColor) + + contentView.backgroundColor = WordPressAuthenticator.shared.style.viewControllerBackgroundColor + } +} + +// MARK: - Settings +// +private extension SearchTableViewCell { + enum Constants { + static let textInsetsWithIcon = WPStyleGuide.edgeInsetForLoginTextFields() + } +} + +// MARK: - UITextFieldDelegate +// +extension SearchTableViewCell: UITextFieldDelegate { + open func textFieldShouldClear(_ textField: UITextField) -> Bool { + if !liveSearch { + delegate?.startSearch(for: "") + } + + return true + } + + open func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if !liveSearch, + let searchText = textField.text { + delegate?.startSearch(for: searchText) + } + + return false + } + + open func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + let sanitizedString: String + + if allowSpaces { + sanitizedString = string + } else { + sanitizedString = string.trimmingCharacters(in: .whitespacesAndNewlines) + } + + let hasValidEdits = sanitizedString.count > 0 || range.length > 0 + + if hasValidEdits { + guard let start = textField.position(from: textField.beginningOfDocument, offset: range.location), + let end = textField.position(from: start, offset: range.length), + let textRange = textField.textRange(from: start, to: end) else { + + // This shouldn't really happen but if it does, let's at least let the edit go through + return true + } + + textField.replace(textRange, withText: sanitizedString) + + if liveSearch { + startLiveSearch() + } + } + + return false + } + + /// Convenience method to abstract the logic that tells the delegate to start a live search. + /// + /// - Precondition: make sure you check if `liveSearch` is enabled before calling this method. + /// + private func startLiveSearch() { + guard let delegate = delegate, + let text = textField.text else { + return + } + + if text.count == 0 { + delegate.startSearch(for: "") + } else { + delegate.startSearch(for: text) + } + } +} + +// MARK: - Loader +// +public extension SearchTableViewCell { + func showLoader() { + guard let leftView = textField.leftView else { return } + let spinner = UIActivityIndicatorView(frame: leftView.frame) + addSubview(spinner) + spinner.startAnimating() + + textField.leftView?.alpha = 0 + } + + func hideLoader() { + for subview in subviews where subview is UIActivityIndicatorView { + subview.removeFromSuperview() + break + } + + textField.leftView?.alpha = 1 + } +} diff --git a/WordPressAuthenticator/Sources/UI/SearchTableViewCell.xib b/WordPressAuthenticator/Sources/UI/SearchTableViewCell.xib new file mode 100644 index 000000000000..a52ee3c47f84 --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/SearchTableViewCell.xib @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/UI/SiteInfoHeaderView.swift b/WordPressAuthenticator/Sources/UI/SiteInfoHeaderView.swift new file mode 100644 index 000000000000..220c10495b0f --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/SiteInfoHeaderView.swift @@ -0,0 +1,116 @@ +import UIKit +import WordPressShared + +// MARK: - SiteInfoHeaderView +// +class SiteInfoHeaderView: UIView { + + // MARK: - Outlets + @IBOutlet private var titleLabel: UILabel! + @IBOutlet private var subtitleLabel: UILabel! + @IBOutlet private var blavatarImageView: UIImageView! + + // MARK: - Properties + + /// Site Title + /// + var title: String? { + get { + return titleLabel.text + } + set { + titleLabel.text = newValue + } + } + + /// Site Subtitle + /// + var subtitle: String? { + get { + return subtitleLabel.text + } + set { + subtitleLabel.text = newValue + } + } + + /// When enabled, the Subtitle won't be rendered. + /// + var subtitleIsHidden: Bool = true { + didSet { + refreshLabelStyles() + } + } + + /// When enabled, renders a border around the Blavatar. + /// + var blavatarBorderIsHidden: Bool = false { + didSet { + refreshBlavatarStyle() + } + } + + /// Returns (or sets) the Site's Blavatar Image. + /// + var blavatarImage: UIImage? { + get { + return blavatarImageView.image + } + set { + blavatarImageView.image = newValue + } + } + + /// Downloads the Blavatar Image at the specified URL. + /// + func downloadBlavatar(at path: String) { + blavatarImageView.image = .siteIconPlaceholderImage + + if let url = URL(string: path) { + blavatarImageView.downloadImage(from: url) + } + } + + // MARK: - Overriden Methods + + override func awakeFromNib() { + super.awakeFromNib() + refreshLabelStyles() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else { + return + } + + refreshLabelStyles() + } +} + +// MARK: - Private +// +private extension SiteInfoHeaderView { + + func refreshLabelStyles() { + let titleWeight: UIFont.Weight = subtitleIsHidden ? .regular : .semibold + titleLabel.font = WPStyleGuide.fontForTextStyle(.subheadline, fontWeight: titleWeight) + titleLabel.textColor = WPStyleGuide.darkGrey() + + subtitleLabel.isHidden = subtitleIsHidden + subtitleLabel.font = WPStyleGuide.fontForTextStyle(.footnote) + subtitleLabel.textColor = WPStyleGuide.darkGrey() + } + + func refreshBlavatarStyle() { + if blavatarBorderIsHidden { + blavatarImageView.layer.borderWidth = 0 + blavatarImageView.tintColor = WordPressAuthenticator.shared.style.placeholderColor + } else { + blavatarImageView.layer.borderColor = WordPressAuthenticator.shared.style.instructionColor.cgColor + blavatarImageView.layer.borderWidth = 1 + blavatarImageView.tintColor = WordPressAuthenticator.shared.style.placeholderColor + } + } +} diff --git a/WordPressAuthenticator/Sources/UI/WebAuthenticationPresentationContext.swift b/WordPressAuthenticator/Sources/UI/WebAuthenticationPresentationContext.swift new file mode 100644 index 000000000000..6569ed913ccd --- /dev/null +++ b/WordPressAuthenticator/Sources/UI/WebAuthenticationPresentationContext.swift @@ -0,0 +1,15 @@ +import AuthenticationServices +import Foundation + +class WebAuthenticationPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + let viewController: UIViewController + + init(viewController: UIViewController) { + self.viewController = viewController + super.init() + } + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + return viewController.view.window! + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/GoogleAuthenticator.swift b/WordPressAuthenticator/Sources/Unified Auth/GoogleAuthenticator.swift new file mode 100644 index 000000000000..d995bf75ed23 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/GoogleAuthenticator.swift @@ -0,0 +1,446 @@ +import Foundation +import WordPressKit +import SVProgressHUD + +/// Contains delegate methods for Google authentication unified auth flow. +/// Both Login and Signup are handled via this delegate. +/// +protocol GoogleAuthenticatorDelegate: AnyObject { + // Google account login was successful. + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account login was successful, but a WP 2FA code is required. + func googleNeedsMultifactorCode(loginFields: LoginFields) + + // Google account login was successful, but a WP password is required. + func googleExistingUserNeedsConnection(loginFields: LoginFields) + + // Google account login failed. + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields, unknownUser: Bool) + + // Google account selection cancelled by user. + func googleAuthCancelled() + + // Google account signup was successful. + func googleFinishedSignup(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account signup redirected to login was successful. + func googleLoggedInInstead(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account signup failed. + func googleSignupFailed(error: Error, loginFields: LoginFields) +} + +/// Indicate which type of authentication is initiated. +/// Utilized by ViewControllers that handle separate Google Login and Signup flows. +/// This is needed as long as: +/// Separate Google Login and Signup flows are utilized. +/// Tracking is specific to separate Login and Signup flows. +/// When separate Google Login and Signup flows are no longer used, this no longer needed. +/// +enum GoogleAuthType { + case login + case signup +} + +/// Contains delegate methods for Google login specific flow. +/// When separate Google Login and Signup flows are no longer used, this no longer needed. +/// +protocol GoogleAuthenticatorLoginDelegate: AnyObject { + // Google account login was successful. + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account login was successful, but a WP 2FA code is required. + func googleNeedsMultifactorCode(loginFields: LoginFields) + + // Google account login was successful, but a WP password is required. + func googleExistingUserNeedsConnection(loginFields: LoginFields) + + // Google account login failed. + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields) +} + +/// Contains delegate methods for Google signup specific flow. +/// When separate Google Login and Signup flows are no longer used, this no longer needed. +/// +protocol GoogleAuthenticatorSignupDelegate: AnyObject { + // Google account signup was successful. + func googleFinishedSignup(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account signup redirected to login was successful. + func googleLoggedInInstead(credentials: AuthenticatorCredentials, loginFields: LoginFields) + + // Google account signup failed. + func googleSignupFailed(error: Error, loginFields: LoginFields) + + // Google account signup cancelled by user. + func googleSignupCancelled() +} + +class GoogleAuthenticator: NSObject { + + // MARK: - Properties + + static var sharedInstance: GoogleAuthenticator = GoogleAuthenticator() + weak var loginDelegate: GoogleAuthenticatorLoginDelegate? + weak var signupDelegate: GoogleAuthenticatorSignupDelegate? + weak var delegate: GoogleAuthenticatorDelegate? + + private var loginFields = LoginFields() + private let authConfig = WordPressAuthenticator.shared.configuration + private var authType: GoogleAuthType = .login + + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + private lazy var loginFacade: LoginFacade = { + let facade = LoginFacade(dotcomClientID: authConfig.wpcomClientId, + dotcomSecret: authConfig.wpcomSecret, + userAgent: authConfig.userAgent) + facade.delegate = self + return facade + }() + + private weak var authenticationDelegate: WordPressAuthenticatorDelegate? = { + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError() + } + return delegate + }() + + // MARK: - Start Authentication + + /// Public method to initiate the Google auth process. + /// - Parameters: + /// - viewController: The UIViewController that Google is being presented from. + /// Required by Google SDK. + /// - loginFields: LoginFields from the calling view controller. + /// The values are updated during the Google process, + /// and returned to the calling view controller via delegate methods. + /// - authType: Indicates the type of authentication (login or signup) + func showFrom( + viewController: UIViewController, + loginFields: LoginFields, + for authType: GoogleAuthType = .login + ) { + // The fact that we set `loginFields`, then reset its `meta.socialService` property doesn't + // seem ideal... + self.loginFields = loginFields + self.loginFields.meta.socialService = SocialServiceName.google + self.authType = authType + + Task { @MainActor in + do { + let token = try await requestAuthorization( + for: authType, + from: viewController, + loginFields: loginFields + ) + + didSignIn(token: token.token.rawValue, email: token.email, fullName: token.name) + } catch { + failedToSignIn(error: error) + } + } + } + + /// Public method to create a WP account with a Google account. + /// - Parameters: + /// - loginFields: LoginFields from the calling view controller. + /// The values are updated during the Google process, + /// and returned to the calling view controller via delegate methods. + func createGoogleAccount(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let token = loginFields.meta.socialServiceIDToken else { + WPAuthenticatorLogError("GoogleAuthenticator - createGoogleAccount: Failed to get Google account information.") + return + } + + createWordPressComUser(token: token, email: loginFields.emailAddress) + } + +} + +// MARK: - Private Extension + +private extension GoogleAuthenticator { + + private func trackRequestAuthorizitation(type: GoogleAuthType) { + switch type { + case .login: + tracker.set(flow: .loginWithGoogle) + tracker.track(step: .start) { + track(.loginSocialButtonClick) + } + case .signup: + track(.createAccountInitiated) + } + } + + func track(_ event: WPAnalyticsStat, properties: [AnyHashable: Any] = [:]) { + var trackProperties = properties + trackProperties["source"] = "google" + WordPressAuthenticator.track(event, properties: trackProperties) + } + + private func failedToSignIn(error: Error?) { + // The Google SignIn may have been cancelled. + // + // FIXME: Is `error == .none` how we distinguish between user cancellation and legit error? + let failure = error?.localizedDescription ?? "Unknown error" + + tracker.track(failure: failure, ifTrackingNotEnabled: { + let properties = ["error": failure] + + switch authType { + case .login: + track(.loginSocialButtonFailure, properties: properties) + case .signup: + track(.signupSocialButtonFailure, properties: properties) + } + }) + + // Notify the delegates so the Google Auth view can be dismissed. + // + // FIXME: Shouldn't we be calling a method to report error, if there was one? + signupDelegate?.googleSignupCancelled() + delegate?.googleAuthCancelled() + } + + private func didSignIn(token: String, email: String, fullName: String) { + // Save account information to pass back to delegate later. + loginFields.emailAddress = email + loginFields.username = email + loginFields.meta.socialServiceIDToken = token + loginFields.meta.socialUser = SocialUser(email: email, fullName: fullName, service: .google) + + guard authConfig.enableUnifiedAuth else { + // Initiate separate WP login / signup paths. + switch authType { + case .login: + SVProgressHUD.show() + loginFacade.loginToWordPressDotCom(withSocialIDToken: token, service: SocialServiceName.google.rawValue) + case .signup: + createWordPressComUser(token: token, email: email) + } + + return + } + + // Initiate unified path by attempting to login first. + // + // `SVProgressHUD.show()` will crash in an app that doesn't have a window property in its + // `UIApplicationDelegate`, such as those created via the Xcode templates circa version 12 + // onwards. + SVProgressHUD.show() + loginFacade.loginToWordPressDotCom(withSocialIDToken: token, service: SocialServiceName.google.rawValue) + } + + enum LocalizedText { + static let googleConnected = NSLocalizedString("Connected But…", comment: "Title shown when a user logs in with Google but no matching WordPress.com account is found") + static let googleConnectedError = NSLocalizedString("The Google account \"%@\" doesn't match any account on WordPress.com", comment: "Description shown when a user logs in with Google but no matching WordPress.com account is found") + static let googleUnableToConnect = NSLocalizedString("Unable To Connect", comment: "Shown when a user logs in with Google but it subsequently fails to work as login to WordPress.com") + } + +} + +// MARK: - SDK-less flow + +extension GoogleAuthenticator { + + private func requestAuthorization( + for authType: GoogleAuthType, + from viewController: UIViewController, + loginFields: LoginFields + ) async throws -> IDToken { + // Intentionally duplicated from the callsite, so we don't forget about this when removing + // the SDK. + // + // The fact that we set `loginFields`, then reset its `meta.socialService` property doesn't + // seem ideal... + self.loginFields = loginFields + self.loginFields.meta.socialService = SocialServiceName.google + self.authType = authType + + trackRequestAuthorizitation(type: authType) + + let sdkLessGoogleAuthenticator = NewGoogleAuthenticator( + clientId: authConfig.googleClientId, + scheme: authConfig.googleLoginScheme, + audience: authConfig.googleLoginServerClientId, + urlSession: .shared + ) + + await SVProgressHUD.show() + return try await sdkLessGoogleAuthenticator.getOAuthToken(from: viewController) + } +} + +// MARK: - LoginFacadeDelegate + +extension GoogleAuthenticator: LoginFacadeDelegate { + + // Google account login was successful. + func finishedLogin(withGoogleIDToken googleIDToken: String, authToken: String) { + SVProgressHUD.dismiss() + + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + track(.signedIn) + + if tracker.shouldUseLegacyTracker() { + track(.loginSocialSuccess) + } + + let wpcom = WordPressComCredentials(authToken: authToken, + isJetpackLogin: loginFields.meta.jetpackLogin, + multifactor: false, + siteURL: loginFields.siteAddress) + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + loginDelegate?.googleFinishedLogin(credentials: credentials, loginFields: loginFields) + delegate?.googleFinishedLogin(credentials: credentials, loginFields: loginFields) + } + + // Google account login was successful, but a WP 2FA code is required. + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + SVProgressHUD.dismiss() + + loginFields.nonceInfo = nonceInfo + loginFields.nonceUserID = userID + + if tracker.shouldUseLegacyTracker() { + track(.loginSocial2faNeeded) + } + + loginDelegate?.googleNeedsMultifactorCode(loginFields: loginFields) + delegate?.googleNeedsMultifactorCode(loginFields: loginFields) + } + + // Google account login was successful, but a WP password is required. + func existingUserNeedsConnection(_ email: String) { + SVProgressHUD.dismiss() + + loginFields.username = email + loginFields.emailAddress = email + + if tracker.shouldUseLegacyTracker() { + track(.loginSocialAccountsNeedConnecting) + } + + loginDelegate?.googleExistingUserNeedsConnection(loginFields: loginFields) + delegate?.googleExistingUserNeedsConnection(loginFields: loginFields) + } + + // Google account login failed. + func displayRemoteError(_ error: Error) { + SVProgressHUD.dismiss() + + var errorTitle = LocalizedText.googleUnableToConnect + var errorDescription = error.localizedDescription + let unknownUser = (error as? WordPressComOAuthError)?.authenticationFailureKind == .unknownUser + + if unknownUser { + errorTitle = LocalizedText.googleConnected + errorDescription = String(format: LocalizedText.googleConnectedError, loginFields.username) + + if tracker.shouldUseLegacyTracker() { + track(.loginSocialErrorUnknownUser) + } + } else { + // Don't track unknown user for unified Auth. + tracker.track(failure: errorDescription) + } + + loginDelegate?.googleLoginFailed(errorTitle: errorTitle, errorDescription: errorDescription, loginFields: loginFields) + delegate?.googleLoginFailed(errorTitle: errorTitle, errorDescription: errorDescription, loginFields: loginFields, unknownUser: unknownUser) + } + +} + +// MARK: - Sign Up Methods + +private extension GoogleAuthenticator { + + /// Creates a WordPress.com account with the associated Google token and email. + /// + func createWordPressComUser(token: String, email: String) { + SVProgressHUD.show() + let service = SignupService() + + service.createWPComUserWithGoogle(token: token, success: { [weak self] accountCreated, wpcomUsername, wpcomToken in + + let wpcom = WordPressComCredentials(authToken: wpcomToken, isJetpackLogin: false, multifactor: false, siteURL: self?.loginFields.siteAddress ?? "") + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + // New Account + if accountCreated { + SVProgressHUD.dismiss() + // Notify the host app + self?.authenticationDelegate?.createdWordPressComAccount(username: wpcomUsername, authToken: wpcomToken) + // Notify the delegate + self?.accountCreated(credentials: credentials) + + return + } + + // Existing Account + // Sync host app + self?.authenticationDelegate?.sync(credentials: credentials) { + SVProgressHUD.dismiss() + // Notify delegate + self?.logInInstead(credentials: credentials) + } + + }, failure: { [weak self] error in + SVProgressHUD.dismiss() + // Notify delegate + self?.signupFailed(error: error) + }) + } + + func accountCreated(credentials: AuthenticatorCredentials) { + // This stat is part of a funnel that provides critical information. Before + // making ANY modification to this stat please refer to: p4qSXL-35X-p2 + track(.createdAccount) + + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + track(.signedIn) + + tracker.track(step: .success, ifTrackingNotEnabled: { + track(.signupSocialSuccess) + }) + + signupDelegate?.googleFinishedSignup(credentials: credentials, loginFields: loginFields) + delegate?.googleFinishedSignup(credentials: credentials, loginFields: loginFields) + } + + func logInInstead(credentials: AuthenticatorCredentials) { + tracker.set(flow: .loginWithGoogle) + + // This stat is part of a funnel that provides critical information. Please + // consult with your lead before removing this event. + track(.signedIn) + + tracker.track(step: .start) { + track(.signupSocialToLogin) + track(.loginSocialSuccess) + } + + signupDelegate?.googleLoggedInInstead(credentials: credentials, loginFields: loginFields) + delegate?.googleLoggedInInstead(credentials: credentials, loginFields: loginFields) + } + + func signupFailed(error: Error) { + tracker.track(failure: error.localizedDescription, ifTrackingNotEnabled: { + track(.signupSocialFailure, properties: ["error": error.localizedDescription]) + }) + + signupDelegate?.googleSignupFailed(error: error, loginFields: loginFields) + delegate?.googleSignupFailed(error: error, loginFields: loginFields) + } + +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsAuthenticator.swift b/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsAuthenticator.swift new file mode 100644 index 000000000000..257bd8d17d70 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsAuthenticator.swift @@ -0,0 +1,245 @@ +import Foundation +import AuthenticationServices +import SVProgressHUD + +/// The authorization flow handled by this class starts by showing Apple's `ASAuthorizationController` +/// through our class `StoredCredentialsPicker`. This controller lets the user pick the credentials they +/// want to login with. This class handles both showing that controller and executing the remaining flow to +/// complete the login process. +/// +class StoredCredentialsAuthenticator: NSObject { + + // MARK: - Delegates + + private var authenticationDelegate: WordPressAuthenticatorDelegate { + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError() + } + return delegate + } + + // MARK: - Configuration + + private var authConfig: WordPressAuthenticatorConfiguration { + WordPressAuthenticator.shared.configuration + } + + // MARK: - Login Support + + private lazy var loginFacade: LoginFacade = { + let facade = LoginFacade(dotcomClientID: authConfig.wpcomClientId, + dotcomSecret: authConfig.wpcomSecret, + userAgent: authConfig.userAgent) + facade.delegate = self + return facade + }() + + // MARK: - Cancellation + + private let onCancel: (() -> Void)? + + // MARK: - UI + + private let picker = StoredCredentialsPicker() + private weak var navigationController: UINavigationController? + + // MARK: - Tracking Support + + private var tracker: AuthenticatorAnalyticsTracker { + AuthenticatorAnalyticsTracker.shared + } + + // MARK: - Login Fields + + private var loginFields: LoginFields? + + // MARK: - Initialization + + init(onCancel: (() -> Void)? = nil) { + self.onCancel = onCancel + } + + // MARK: - Picker + + /// Shows the UI for picking stored credentials for the user to log into their account. + /// + func showPicker(from navigationController: UINavigationController) { + self.navigationController = navigationController + + guard let window = navigationController.view.window else { + WPAuthenticatorLogError("Can't obtain window for navigation controller") + return + } + + picker.show(in: window) { [weak self] result in + guard let self = self else { + return + } + + switch result { + case .success(let authorization): + self.pickerSuccess(authorization) + case .failure(let error): + self.pickerFailure(error) + } + } + } + + /// The selection of credentials and subsequent authorization by the OS succeeded. This method processes the credentials + /// and proceeds with the login operation. + /// + /// - Parameters: + /// - authorization: The authorization by the OS, containing the credentials picked by the user. + /// + private func pickerSuccess(_ authorization: ASAuthorization) { + tracker.track(step: .start) + tracker.set(flow: .loginWithiCloudKeychain) + SVProgressHUD.show() + + switch authorization.credential { + case _ as ASAuthorizationAppleIDCredential: + // No-op for now, but we can decide to implement AppleID login through this authenticator + // by implementing the logic here. + break + case let credential as ASPasswordCredential: + let loginFields = LoginFields.makeForWPCom(username: credential.user, password: credential.password) + loginFacade.signIn(with: loginFields) + self.loginFields = loginFields + default: + // There aren't any other known methods for us to handle here, but we still need to complete the switch + // statement. + break + } + } + + /// The selection of credentials or the subsequent authorization by the OS failed. This method processes the failure. + /// + /// - Parameters: + /// - error: The error detailing what failed. + /// + private func pickerFailure(_ error: Error) { + let authError = ASAuthorizationError(_nsError: error as NSError) + + switch authError.code { + case .canceled: + // The user cancelling the flow is not really an error, so we're not reporting or tracking + // this as an error. + // + // We're not tracking this either, since the Android App doesn't for SmartLock. The reason is + // that it's not trivial to know when the credentials picker UI is shown to the user, so knowing + // it's being dismissed is also not trivial. This was decided during the Unified Login & Signup + // project in a conversation between myself (Diego Rey Mendez) and Renan Ferrari. + break + default: + tracker.track(failure: authError.localizedDescription) + WPAuthenticatorLogError("ASAuthorizationError: \(authError.localizedDescription)") + } + } +} + +extension StoredCredentialsAuthenticator: LoginFacadeDelegate { + func displayRemoteError(_ error: Error) { + tracker.track(failure: error.localizedDescription) + SVProgressHUD.dismiss() + + guard authConfig.enableUnifiedAuth else { + presentLoginEmailView(error: error) + return + } + + presentGetStartedView(error: error) + } + + func needsMultifactorCode() { + SVProgressHUD.dismiss() + presentTwoFactorAuthenticationView() + } + + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + loginFields?.nonceInfo = nonceInfo + loginFields?.nonceUserID = userID + + needsMultifactorCode() + } + + func finishedLogin(withAuthToken authToken: String, requiredMultifactorCode: Bool) { + let wpcom = WordPressComCredentials( + authToken: authToken, + isJetpackLogin: false, + multifactor: requiredMultifactorCode, + siteURL: "") + let credentials = AuthenticatorCredentials(wpcom: wpcom) + + authenticationDelegate.sync(credentials: credentials) { [weak self] in + SVProgressHUD.dismiss() + self?.presentLoginEpilogue(credentials: credentials) + } + } +} + +// MARK: - UI Flow + +extension StoredCredentialsAuthenticator { + private func presentLoginEpilogue(credentials: AuthenticatorCredentials) { + guard let navigationController = self.navigationController else { + WPAuthenticatorLogError("No navigation controller to present the login epilogue from") + return + } + + authenticationDelegate.presentLoginEpilogue(in: navigationController, + for: credentials, + source: WordPressAuthenticator.shared.signInSource, + onDismiss: {}) + } + + /// Presents the login email screen, displaying the specified error. This is useful + /// for example for iCloud Keychain in the case where there's an error logging the user + /// in with the stored credentials for whatever reason. + /// + private func presentLoginEmailView(error: Error) { + guard let toVC = LoginEmailViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate to LoginEmailVC from LoginPrologueVC") + return + } + + if let loginFields = loginFields { + toVC.loginFields = loginFields + } + toVC.errorToPresent = error + + navigationController?.pushViewController(toVC, animated: true) + } + + /// Presents the get started screen, displaying the specified error. This is useful + /// for example for iCloud Keychain in the case where there's an error logging the user + /// in with the stored credentials for whatever reason. + /// + private func presentGetStartedView(error: Error) { + guard let toVC = GetStartedViewController.instantiate(from: .getStarted) else { + WPAuthenticatorLogError("Failed to navigate to GetStartedViewController") + return + } + + if let loginFields = loginFields { + toVC.loginFields = loginFields + } + + toVC.errorMessage = error.localizedDescription + navigationController?.pushViewController(toVC, animated: true) + } + + private func presentTwoFactorAuthenticationView() { + guard let loginFields = loginFields else { + return + } + + guard let vc = TwoFAViewController.instantiate(from: .twoFA) else { + WPAuthenticatorLogError("Failed to navigate from LoginViewController to TwoFAViewController") + return + } + + vc.loginFields = loginFields + + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsPicker.swift b/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsPicker.swift new file mode 100644 index 000000000000..a0668f5ab977 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/StoredCredentialsPicker.swift @@ -0,0 +1,55 @@ +import Foundation +import AuthenticationServices + +/// Thin wrapper around `ASAuthorizationController` to avoid having to set delegate methods in the VC +/// and to modularize / abstract the logic to show Apple's UI for picking the stored credentials. +/// +/// This picker takes care of returning the credentials that were picked (and authorized by the iOS) through a closure. +/// It's not within the scope of this class to take care of what happens after the credentials are picked. +/// +class StoredCredentialsPicker: NSObject { + + typealias CompletionClosure = (Result) -> Void + + /// The closure that will be executed once the credentials are picked and returned by the OS, + /// or once there's an Error. + /// + private var onComplete: CompletionClosure! + + /// The window where the quick authentication flow will be shown. + /// + private var window: UIWindow! + + func show(in window: UIWindow, onComplete: @escaping CompletionClosure) { + + self.onComplete = onComplete + self.window = window + + let requests = [ASAuthorizationPasswordProvider().createRequest()] + let controller = ASAuthorizationController(authorizationRequests: requests) + + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } +} + +// MARK: - ASAuthorizationControllerDelegate + +extension StoredCredentialsPicker: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + onComplete(.success(authorization)) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + onComplete(.failure(error)) + } +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding + +extension StoredCredentialsPicker: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return window + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFA.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFA.storyboard new file mode 100644 index 000000000000..19b8e002cb02 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFA.storyboard @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFAViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFAViewController.swift new file mode 100644 index 000000000000..8096ff315604 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/2FA/TwoFAViewController.swift @@ -0,0 +1,713 @@ +import UIKit +import WordPressKit +import SVProgressHUD +import AuthenticationServices + +/// TwoFAViewController: view to enter 2FA code. +/// +final class TwoFAViewController: LoginViewController { + + // MARK: - Properties + + @IBOutlet private weak var tableView: UITableView! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + private weak var codeField: UITextField? + + private var rows = [Row]() + private var errorMessage: String? + private var pasteboardChangeCountBeforeBackground: Int? + private var shouldChangeVoiceOverFocus: Bool = false + + /// Tracks when the initial challenge request was made. + private var initialChallengeRequestTime: Date? + + override var sourceTag: WordPressSupportSourceTag { + get { + return .login2FA + } + } + + // Required for `NUXKeyboardResponder` but unused here. + var verticalCenterConstraint: NSLayoutConstraint? + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + removeGoogleWaitingView() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + configureForAccessibility() + } + + override func viewDidAppear(_ animated: Bool) { + + super.viewDidAppear(animated) + + if isMovingToParent { + tracker.track(step: .twoFactorAuthentication) + } else { + tracker.set(step: .twoFactorAuthentication) + } + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + configureSubmitButton(animating: false) + configureViewForEditingIfNeeded() + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(applicationBecameInactive), name: UIApplication.willResignActiveNotification, object: nil) + nc.addObserver(self, selector: #selector(applicationBecameActive), name: UIApplication.didBecomeActiveNotification, object: nil) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + + // Multifactor codes are time sensitive, so clear the stored code if the + // user dismisses the view. They'll need to reenter it upon return. + loginFields.multifactorCode = "" + codeField?.text = "" + } + + // MARK: - Overrides + + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? + WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + let isNumeric = loginFields.multifactorCode.rangeOfCharacter(from: CharacterSet.decimalDigits.inverted) == nil + let isValidLength = SocialLogin2FANonceInfo.TwoFactorTypeLengths(rawValue: loginFields.multifactorCode.count) != nil + + submitButton?.isEnabled = ( + !animating && + isNumeric && + isValidLength + ) + } + + override func configureViewLoading(_ loading: Bool) { + super.configureViewLoading(loading) + codeField?.isEnabled = !loading + initialChallengeRequestTime = nil + } + + override func displayRemoteError(_ error: Error) { + displayError(message: "") + + let err = error as NSError + + // If the error happened because the security key challenge request started more than 1 minute ago, show a timeout error. + // This check is needed because the server sends a generic error. + if let initialChallengeRequestTime, Date().timeIntervalSince(initialChallengeRequestTime) >= 60, err.code == .zero { + return displaySecurityKeyErrorMessageAndExitFlow(message: LocalizedText.timeoutError) + } + + configureViewLoading(false) + if (error as? WordPressComOAuthError)?.authenticationFailureKind == .invalidOneTimePassword { + // Invalid verification code. + displayError(message: LocalizedText.bad2FAMessage, moveVoiceOverFocus: true) + } else if case let .endpointError(authenticationFailure) = (error as? WordPressComOAuthError), authenticationFailure.kind == .invalidTwoStepCode { + // Invalid 2FA during social login + if let newNonce = authenticationFailure.newNonce { + loginFields.nonceInfo?.updateNonce(with: newNonce) + } + displayError(message: LocalizedText.bad2FAMessage, moveVoiceOverFocus: true) + } else { + displayError(error, sourceTag: sourceTag) + } + } + + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + if errorMessage != message { + if !message.isEmpty { + tracker.track(failure: message) + } + + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } + +} + +// MARK: - Validation and Login + +private extension TwoFAViewController { + + // MARK: - Button Actions + + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .submitTwoFactorCode) + validateForm() + } + + func requestCode() { + SVProgressHUD.showSuccess(withStatus: LocalizedText.smsSent) + SVProgressHUD.dismiss(withDelay: TimeInterval(1)) + + if loginFields.nonceInfo != nil { + // social login + loginFacade.requestSocial2FACode(with: loginFields) + } else { + loginFacade.requestOneTimeCode(with: loginFields) + } + } + + // MARK: - Login + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + func validateForm() { + guard let nonceInfo = loginFields.nonceInfo else { + return validateFormAndLogin() + } + + let (authType, nonce) = nonceInfo.authTypeAndNonce(for: loginFields.multifactorCode) + if nonce.isEmpty { + return validateFormAndLogin() + } + + loginWithNonce(nonce, authType: authType, code: loginFields.multifactorCode) + } + + func loginWithNonce(_ nonce: String, authType: String, code: String) { + configureViewLoading(true) + loginFacade.loginToWordPressDotCom(withUser: loginFields.nonceUserID, authType: authType, twoStepCode: code, twoStepNonce: nonce) + } + + func finishedLogin(withNonceAuthToken authToken: String) { + let wpcom = WordPressComCredentials(authToken: authToken, isJetpackLogin: isJetpackLogin, multifactor: true, siteURL: loginFields.siteAddress) + let credentials = AuthenticatorCredentials(wpcom: wpcom) + syncWPComAndPresentEpilogue(credentials: credentials) + } + + // MARK: - Security Keys + + @available(iOS 16, *) + func loginWithSecurityKeys() { + + guard let twoStepNonce = loginFields.nonceInfo?.nonceWebauthn else { + return displaySecurityKeyErrorMessageAndExitFlow() + } + + configureViewLoading(true) + initialChallengeRequestTime = Date() + + Task { @MainActor in + guard let challengeInfo = await loginFacade.requestWebauthnChallenge(userID: loginFields.nonceUserID, twoStepNonce: twoStepNonce) else { + return displaySecurityKeyErrorMessageAndExitFlow() + } + + signChallenge(challengeInfo) + } + } + + @available(iOS 16, *) + func signChallenge(_ challengeInfo: WebauthnChallengeInfo) { + + loginFields.nonceInfo?.updateNonce(with: challengeInfo.twoStepNonce) + loginFields.webauthnChallengeInfo = challengeInfo + + let challenge = Data(base64URLEncoded: challengeInfo.challenge) ?? Data() + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: challengeInfo.rpID) + let platformKeyRequest = platformProvider.createCredentialAssertionRequest(challenge: challenge) + + let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest]) + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() + } + + // When an security key error occurs, we need to restart the flow to regenerate the necessary nonces. + func displaySecurityKeyErrorMessageAndExitFlow(message: String = LocalizedText.unknownError) { + configureViewLoading(false) + displayErrorAlert(message, sourceTag: .loginWebauthn, onDismiss: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }) + } + + // MARK: - Code Validation + + enum CodeValidation { + case invalid(nonNumbers: Bool) + case valid(String) + } + + func isValidCode(code: String) -> CodeValidation { + let codeStripped = code.components(separatedBy: .whitespacesAndNewlines).joined() + let allowedCharacters = CharacterSet.decimalDigits + let resultCharacterSet = CharacterSet(charactersIn: codeStripped) + let isOnlyNumbers = allowedCharacters.isSuperset(of: resultCharacterSet) + let isShortEnough = codeStripped.count <= SocialLogin2FANonceInfo.TwoFactorTypeLengths.backup.rawValue + + if isOnlyNumbers && isShortEnough { + return .valid(codeStripped) + } + + if isOnlyNumbers { + return .invalid(nonNumbers: false) + } + + return .invalid(nonNumbers: true) + } + + // MARK: - Text Field Handling + + func handleTextFieldDidChange(_ sender: UITextField) { + loginFields.multifactorCode = codeField?.nonNilTrimmedText() ?? "" + configureSubmitButton(animating: false) + } + +} + +// MARK: - Security Keys +extension TwoFAViewController: ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + + // Validate necessary data + guard #available(iOS 16, *), + let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion, + let challengeInfo = loginFields.webauthnChallengeInfo, + let clientDataJson = extractClientData(from: credential, challengeInfo: challengeInfo) else { + return displaySecurityKeyErrorMessageAndExitFlow() + } + + // Validate that the submitted passkey is allowed. + guard challengeInfo.allowedCredentialIDs.contains(credential.credentialID.base64URLEncodedString()) else { + return displaySecurityKeyErrorMessageAndExitFlow(message: LocalizedText.invalidKey) + } + + loginFacade.authenticateWebauthnSignature(userID: loginFields.nonceUserID, + twoStepNonce: challengeInfo.twoStepNonce, + credentialID: credential.credentialID, + clientDataJson: clientDataJson, + authenticatorData: credential.rawAuthenticatorData, + signature: credential.signature, + userHandle: credential.userID) + } + + // Some password managers(like 1P) don't deliver `rawClientDataJSON`. In those cases we need to assemble it manually. + @available(iOS 16, *) + func extractClientData(from credential: ASAuthorizationPlatformPublicKeyCredentialAssertion, challengeInfo: WebauthnChallengeInfo) -> Data? { + + if credential.rawClientDataJSON.count > 0 { + return credential.rawClientDataJSON + } + + // We build this manually because we need to guarantee this exact element order. + let rawClientJSON = "{\"type\":\"webauthn.get\",\"challenge\":\"\(challengeInfo.challenge)\",\"origin\":\"https://\(challengeInfo.rpID)\"}" + return rawClientJSON.data(using: .utf8) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + WPAuthenticatorLogError("Error signing challenge: \(error.localizedDescription)") + displaySecurityKeyErrorMessageAndExitFlow() + } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + view.window! + } +} + +// MARK: - UITextFieldDelegate + +extension TwoFAViewController: UITextFieldDelegate { + + /// Only allow digits in the 2FA text field + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString: String) -> Bool { + + guard let fieldText = textField.text as NSString? else { + return true + } + + let resultString = fieldText.replacingCharacters(in: range, with: replacementString) + + switch isValidCode(code: resultString) { + case .valid(let cleanedCode): + displayError(message: "") + + // because the string was stripped of whitespace, we can't return true and we update the textfield ourselves + textField.text = cleanedCode + handleTextFieldDidChange(textField) + case .invalid(nonNumbers: true): + displayError(message: LocalizedText.numericalCode) + default: + if let pasteString = UIPasteboard.general.string, pasteString == replacementString { + displayError(message: LocalizedText.invalidCode) + } + } + + return false + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + validateForm() + return true + } + +} + +// MARK: - UITableViewDataSource + +extension TwoFAViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + return cell + } + +} + +// MARK: - Keyboard Notifications + +extension TwoFAViewController: NUXKeyboardResponder { + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } + +} + +// MARK: - Application state changes + +private extension TwoFAViewController { + + @objc func applicationBecameInactive() { + pasteboardChangeCountBeforeBackground = UIPasteboard.general.changeCount + } + + @objc func applicationBecameActive() { + guard let codeField = codeField else { + return + } + + let emptyField = codeField.text?.isEmpty ?? true + guard emptyField, + pasteboardChangeCountBeforeBackground != UIPasteboard.general.changeCount else { + return + } + + if #available(iOS 14.0, *) { + UIPasteboard.general.detectAuthenticatorCode { [weak self] result in + switch result { + case .success(let authenticatorCode): + self?.handle(code: authenticatorCode, textField: codeField) + case .failure: + break + } + } + } else { + if let pasteString = UIPasteboard.general.string { + handle(code: pasteString, textField: codeField) + } + } + } + + private func handle(code: String, textField: UITextField) { + switch isValidCode(code: code) { + case .valid(let cleanedCode): + displayError(message: "") + textField.text = cleanedCode + handleTextFieldDidChange(textField) + default: + break + } + } + +} + +// MARK: - Table Management + +private extension TwoFAViewController { + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib(), + TextFieldTableViewCell.reuseIdentifier: TextFieldTableViewCell.loadNib(), + TextLinkButtonTableViewCell.reuseIdentifier: TextLinkButtonTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + + tableView.register(SpacerTableViewCell.self, forCellReuseIdentifier: SpacerTableViewCell.reuseIdentifier) + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.instructions, .code] + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + + rows.append(.spacer(20)) + rows.append(.alternateInstructions) + + rows.append(.spacer(4)) + rows.append(.sendCode) + + if #available(iOS 16, *), WordPressAuthenticator.shared.configuration.enablePasskeys, loginFields.nonceInfo?.nonceWebauthn.isEmpty == false { + rows.append(.spacer(4)) + rows.append(.enterSecurityKey) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .alternateInstructions: + configureAlternateInstructionLabel(cell) + case let cell as TextFieldTableViewCell: + configureTextField(cell) + case let cell as TextLinkButtonTableViewCell where row == .sendCode: + configureTextLinkButton(cell) + case let cell as TextLinkButtonTableViewCell where row == .enterSecurityKey: + configureEnterSecurityKeyLinkButton(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + case let cell as SpacerTableViewCell: + if case let .spacer(spacing) = row { + configureSpacerCell(cell, spacing: spacing) + } + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.twoFactorInstructions) + } + + /// Configure the alternate instruction cell. + /// + func configureAlternateInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.twoFactorOtherFormsInstructions) + } + + /// Configure the textfield cell. + /// + func configureTextField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .numericCode, + placeholder: WordPressAuthenticator.shared.displayStrings.twoFactorCodePlaceholder) + + // Save a reference to the first textField so it can becomeFirstResponder. + codeField = cell.textField + cell.textField.delegate = self + + SigninEditingState.signinEditingStateActive = true + if UIAccessibility.isVoiceOverRunning { + // Quiet repetitive VoiceOver elements. + codeField?.placeholder = nil + } + } + + /// Configure the link cell. + /// + func configureTextLinkButton(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.textCodeButtonTitle, icon: .phoneIcon) + + cell.actionHandler = { [weak self] in + guard let self = self else { return } + + self.tracker.track(click: .sendCodeWithText) + self.requestCode() + } + } + + /// Configure the security key link cell. + /// + func configureEnterSecurityKeyLinkButton(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.securityKeyButtonTitle, + icon: .keyIcon, + accessibilityIdentifier: TextLinkButtonTableViewCell.Constants.passkeysID) + + cell.actionHandler = { [weak self] in + guard let self = self else { return } + + self.tracker.track(click: .enterSecurityKey) + if #available(iOS 16, *) { + self.loginWithSecurityKeys() + } + } + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Configure the spacer cell. + /// + func configureSpacerCell(_ cell: SpacerTableViewCell, spacing: CGFloat) { + cell.spacing = spacing + } + + /// Configure the view for an editing state. + /// + func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + codeField?.becomeFirstResponder() + } + } + + /// Sets up accessibility elements in the order which they should be read aloud + /// and chooses which element to focus on at the beginning. + /// + func configureForAccessibility() { + view.accessibilityElements = [ + codeField as Any, + tableView as Any, + submitButton as Any + ] + + UIAccessibility.post(notification: .screenChanged, argument: codeField) + } + + /// Rows listed in the order they were created. + /// + enum Row: Equatable { + case instructions + case code + case alternateInstructions + case sendCode + case enterSecurityKey + case errorMessage + case spacer(CGFloat) + + var reuseIdentifier: String { + switch self { + case .instructions: + return TextLabelTableViewCell.reuseIdentifier + case .code: + return TextFieldTableViewCell.reuseIdentifier + case .alternateInstructions: + return TextLabelTableViewCell.reuseIdentifier + case .sendCode: + return TextLinkButtonTableViewCell.reuseIdentifier + case .enterSecurityKey: + return TextLinkButtonTableViewCell.reuseIdentifier + case .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + case .spacer: + return SpacerTableViewCell.reuseIdentifier + } + } + } + + enum LocalizedText { + static let bad2FAMessage = NSLocalizedString("Whoops, that's not a valid two-factor verification code. Double-check your code and try again!", comment: "Error message shown when an incorrect two factor code is provided.") + static let numericalCode = NSLocalizedString("A verification code will only contain numbers.", comment: "Shown when a user types a non-number into the two factor field.") + static let invalidCode = NSLocalizedString("That doesn't appear to be a valid verification code.", comment: "Shown when a user pastes a code into the two factor field that contains letters or is the wrong length") + static let smsSent = NSLocalizedString("SMS Sent", comment: "One Time Code has been sent via SMS") + static let invalidKey = NSLocalizedString("Whoops, that security key does not seem valid. Please try again with another one", + comment: "Error when the uses chooses an invalid security key on the 2FA screen.") + static let timeoutError = NSLocalizedString("Time's up, but don't worry, your security is our priority. Please try again!", + comment: "Error when the uses takes more than 1 minute to submit a security key.") + static let unknownError = NSLocalizedString("Whoops, something went wrong. Please try again!", comment: "Generic error on the 2FA screen") + } +} + +private extension TwoFAViewController { + /// Simple spacer cell for a table view. + /// + final class SpacerTableViewCell: UITableViewCell { + + /// Static identifier + /// + static let reuseIdentifier = "SpacerTableViewCell" + + /// Gets or sets the desired vertical spacing. + /// + var spacing: CGFloat { + get { + heightConstraint.constant + } + set { + heightConstraint.constant = newValue + } + } + + /// Determines the view height internally + /// + private let heightConstraint: NSLayoutConstraint + + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + + let spacerView = UIView() + spacerView.translatesAutoresizingMaskIntoConstraints = false + heightConstraint = spacerView.heightAnchor.constraint(equalToConstant: 0) + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + addSubview(spacerView) + NSLayoutConstraint.activate([ + spacerView.topAnchor.constraint(equalTo: topAnchor), + spacerView.bottomAnchor.constraint(equalTo: bottomAnchor), + spacerView.leadingAnchor.constraint(equalTo: leadingAnchor), + spacerView.trailingAnchor.constraint(equalTo: trailingAnchor), + heightConstraint + ]) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStarted.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStarted.storyboard new file mode 100644 index 000000000000..36179af4040c --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStarted.storyboard @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStartedViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStartedViewController.swift new file mode 100644 index 000000000000..0d4d0f53d206 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Get Started/GetStartedViewController.swift @@ -0,0 +1,973 @@ +import UIKit +import SafariServices +import WordPressKit + +/// The source for the sign in flow for external tracking. +public enum SignInSource: Equatable { + /// Initiated from the WP.com login CTA. + case wpCom + /// Initiated from the WP.com login flow that starts with site address. + case wpComSiteAddress + /// Other source identifier from the host app. + case custom(source: String) +} + +/// The error during the sign in flow. +public enum SignInError: Error { + case invalidWPComEmail(source: SignInSource) + case invalidWPComPassword(source: SignInSource) + + init?(error: Error, source: SignInSource?) { + // `WordPressComRestApi` currently may return an WordPressComRestApiEndpointError, but it will later be changed + // to return `WordPressAPIError`. We'll handle both cases for now. + var restApiError = error as? WordPressComRestApiEndpointError + + if restApiError == nil, + let apiError = error as? WordPressAPIError, + case let .endpointError(endpointError) = apiError { + restApiError = endpointError + } + + if let restApiError, restApiError.code == .unknown { + if let source = source, restApiError.apiErrorCode == "unknown_user" { + self = .invalidWPComEmail(source: source) + } else { + return nil + } + } + + return nil + } +} + +class GetStartedViewController: LoginViewController, NUXKeyboardResponder { + + private enum ScreenMode { + /// For signing in using .org site credentials + /// + case signInUsingSiteCredentials + + /// For signing in using WPCOM credentials or social accounts + case signInUsingWordPressComOrSocialAccounts + } + + // MARK: - NUXKeyboardResponder constraints + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + + // Required for `NUXKeyboardResponder` but unused here. + var verticalCenterConstraint: NSLayoutConstraint? + + // MARK: - Properties + @IBOutlet private weak var tableView: UITableView! + @IBOutlet private weak var leadingDividerLine: UIView! + @IBOutlet private weak var leadingDividerLineWidth: NSLayoutConstraint! + @IBOutlet private weak var dividerStackView: UIStackView! + @IBOutlet private weak var dividerLabel: UILabel! + @IBOutlet private weak var trailingDividerLine: UIView! + @IBOutlet private weak var trailingDividerLineWidth: NSLayoutConstraint! + + private weak var emailField: UITextField? + // This is to contain the password selected by password auto-fill. + // When it is populated, login is attempted. + @IBOutlet private weak var hiddenPasswordField: UITextField? + + // This is public so it can be set from StoredCredentialsAuthenticator. + var errorMessage: String? + + var source: SignInSource? { + didSet { + WordPressAuthenticator.shared.signInSource = source + } + } + + private var rows = [Row]() + private var buttonViewController: NUXButtonViewController? + private let configuration = WordPressAuthenticator.shared.configuration + private var shouldChangeVoiceOverFocus: Bool = false + + private var passwordCoordinator: PasswordCoordinator? + + /// Sign in with site credentials button will be displayed based on the `screenMode` + /// + private var screenMode: ScreenMode { + guard configuration.enableSiteCredentialsLoginForSelfHostedSites, + loginFields.siteAddress.isEmpty == false else { + return .signInUsingWordPressComOrSocialAccounts + } + return .signInUsingSiteCredentials + } + + // Submit button displayed in the table footer. + private lazy var continueButton: NUXButton = { + let button = NUXButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.isPrimary = true + button.isEnabled = false + button.addTarget(self, action: #selector(handleSubmitButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = ButtonConfiguration.Continue.accessibilityIdentifier + button.setTitle(ButtonConfiguration.Continue.title, for: .normal) + + return button + }() + + // "What is WordPress.com?" button + private lazy var whatisWPCOMButton: UIButton = { + let button = UIButton() + button.setTitle(WordPressAuthenticator.shared.displayStrings.whatIsWPComLinkTitle, for: .normal) + let buttonTitleColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonColor ?? WordPressAuthenticator.shared.style.textButtonColor + let buttonHighlightColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonHighlightColor ?? WordPressAuthenticator.shared.style.textButtonHighlightColor + button.titleLabel?.font = WPStyleGuide.mediumWeightFont(forStyle: .subheadline) + button.setTitleColor(buttonTitleColor, for: .normal) + button.setTitleColor(buttonHighlightColor, for: .highlighted) + button.addTarget(self, action: #selector(whatIsWPComButtonTapped(_:)), for: .touchUpInside) + return button + }() + + private var showsContinueButtonAtTheBottom: Bool { + configuration.enableSocialLogin == false + } + + override open var sourceTag: WordPressSupportSourceTag { + get { + return .loginEmail + } + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + configureNavBar() + setupTable() + registerTableViewCells() + loadRows() + setupTableFooterView() + configureDivider() + + if screenMode == .signInUsingSiteCredentials { + configureButtonViewControllerForSiteCredentialsMode() + } else if configuration.enableSocialLogin == false { + configureButtonViewControllerWithoutSocialLogin() + } else { + configureSocialButtons() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + refreshEmailField() + + // Ensure the continue button matches the validity of the email field + configureContinueButton(animating: false) + + if errorMessage != nil { + shouldChangeVoiceOverFocus = true + } + } + + override open func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + configureAnalyticsTracker() + + errorMessage = nil + hiddenPasswordField?.text = nil + hiddenPasswordField?.isAccessibilityElement = false + + if showsContinueButtonAtTheBottom { + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + tableView.updateFooterHeight() + } + + // MARK: - Overrides + + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? + WordPressAuthenticator.shared.style.statusBarStyle + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let vc = segue.destination as? NUXButtonViewController { + buttonViewController = vc + } + } + + override func configureViewLoading(_ loading: Bool) { + configureContinueButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + override func enableSubmit(animating: Bool) -> Bool { + return !animating && canSubmit() + } + + private func refreshEmailField() { + // It's possible that the password screen could have changed the loginFields username, for example when using + // autofill from a password manager. Let's ensure the loginFields matches the email field. + loginFields.username = emailField?.nonNilTrimmedText() ?? loginFields.username + } +} + +// MARK: - UITableViewDataSource + +extension GetStartedViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + return cell + } + +} + +// MARK: - Private methods + +private extension GetStartedViewController { + + // MARK: - Configuration + + func configureNavBar() { + navigationItem.title = WordPressAuthenticator.shared.displayStrings.getStartedTitle + styleNavigationBar(forUnified: true) + } + + func setupTable() { + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + } + + func setupTableFooterView() { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = Constants.FooterStackView.spacing + stackView.layoutMargins = Constants.FooterStackView.layoutMargins + stackView.isLayoutMarginsRelativeArrangement = true + + if showsContinueButtonAtTheBottom == false { + // Continue button will be added to `buttonViewController` along with sign in with site credentials button when `screenMode` is `signInUsingSiteCredentials` + // and simplified login flow is disabled. + stackView.addArrangedSubview(continueButton) + } + + if configuration.whatIsWPComURL != nil { + let stackViewWithCenterAlignment = UIStackView() + stackViewWithCenterAlignment.axis = .vertical + stackViewWithCenterAlignment.alignment = .center + + stackViewWithCenterAlignment.addArrangedSubview(whatisWPCOMButton) + + stackView.addArrangedSubview(stackViewWithCenterAlignment) + } + + tableView.tableFooterView = stackView + tableView.updateFooterHeight() + } + + /// Style the "OR" divider. + /// + func configureDivider() { + guard showsContinueButtonAtTheBottom == false else { + return dividerStackView.isHidden = true + } + let color = WordPressAuthenticator.shared.unifiedStyle?.borderColor ?? WordPressAuthenticator.shared.style.primaryNormalBorderColor + leadingDividerLine.backgroundColor = color + leadingDividerLineWidth.constant = WPStyleGuide.hairlineBorderWidth + trailingDividerLine.backgroundColor = color + trailingDividerLineWidth.constant = WPStyleGuide.hairlineBorderWidth + dividerLabel.textColor = color + dividerLabel.text = NSLocalizedString("Or", comment: "Divider on initial auth view separating auth options.").localizedUppercase + } + + // MARK: - Continue Button Action + + @objc func handleSubmitButtonTapped() { + tracker.track(click: .submit) + validateForm() + } + + // MARK: - Sign in with site credentials Button Action + @objc func handleSiteCredentialsButtonTapped() { + tracker.track(click: .signInWithSiteCredentials) + goToSiteCredentialsScreen() + } + + // MARK: - What is WordPress.com Button Action + + @IBAction func whatIsWPComButtonTapped(_ sender: UIButton) { + tracker.track(click: .whatIsWPCom) + guard let whatIsWPCom = configuration.whatIsWPComURL else { + return + } + UIApplication.shared.open(whatIsWPCom) + } + + // MARK: - Hidden Password Field Action + + @IBAction func handlePasswordFieldDidChange(_ sender: UITextField) { + attemptAutofillLogin() + } + + // MARK: - Table Management + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib(), + TextFieldTableViewCell.reuseIdentifier: TextFieldTableViewCell.loadNib(), + TextWithLinkTableViewCell.reuseIdentifier: TextWithLinkTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.instructions, .email] + + if let authenticationDelegate = WordPressAuthenticator.shared.delegate, authenticationDelegate.wpcomTermsOfServiceEnabled { + rows.append(.tos) + } + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextFieldTableViewCell: + configureEmailField(cell) + case let cell as TextWithLinkTableViewCell: + configureTextWithLink(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.getStartedInstructions) + } + + /// Configure the email cell. + /// + func configureEmailField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .email, + placeholder: WordPressAuthenticator.shared.displayStrings.emailAddressPlaceholder, + text: loginFields.username) + cell.textField.delegate = self + emailField = cell.textField + + cell.onChangeSelectionHandler = { [weak self] textfield in + self?.loginFields.username = textfield.nonNilTrimmedText() + self?.configureContinueButton(animating: false) + } + + if UIAccessibility.isVoiceOverRunning { + // Quiet repetitive elements in VoiceOver. + emailField?.placeholder = nil + } + } + + /// Configure the link cell. + /// + func configureTextWithLink(_ cell: TextWithLinkTableViewCell) { + cell.configureButton(markedText: WordPressAuthenticator.shared.displayStrings.loginTermsOfService) + + cell.actionHandler = { [weak self] in + self?.termsTapped() + } + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Rows listed in the order they were created. + /// + enum Row { + case instructions + case email + case tos + case errorMessage + + var reuseIdentifier: String { + switch self { + case .instructions, .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + case .email: + return TextFieldTableViewCell.reuseIdentifier + case .tos: + return TextWithLinkTableViewCell.reuseIdentifier + } + } + } + + enum Constants { + enum FooterStackView { + static let spacing = 16.0 + static let layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 16) + } + } + + // MARK: Analytics + // + func configureAnalyticsTracker() { + // Configure tracker flow based on screen mode. + switch screenMode { + case .signInUsingSiteCredentials: + tracker.set(flow: .loginWithSiteAddress) + case .signInUsingWordPressComOrSocialAccounts: + tracker.set(flow: .wpCom) + } + + let stepValue: AuthenticatorAnalyticsTracker.Step = configuration.useEnterEmailAddressAsStepValueForGetStartedVC ? .enterEmailAddress : .start + if isMovingToParent { + tracker.track(step: stepValue) + } else { + tracker.set(step: stepValue) + } + } +} + +// MARK: - Validation + +private extension GetStartedViewController { + + /// Configures appearance of the submit button. + /// + func configureContinueButton(animating: Bool) { + if showsContinueButtonAtTheBottom { + buttonViewController?.setTopButtonState(isLoading: animating, + isEnabled: enableSubmit(animating: animating)) + } else { + continueButton.showActivityIndicator(animating) + continueButton.isEnabled = enableSubmit(animating: animating) + } + } + + /// Whether the form can be submitted. + /// + func canSubmit() -> Bool { + return EmailFormatValidator.validate(string: loginFields.username) + } + + /// Validates email address and proceeds with the submit action. + /// Empties loginFields.meta.socialService as + /// social signin does not require form validation. + /// + func validateForm() { + loginFields.meta.socialService = nil + displayError(message: "") + + guard EmailFormatValidator.validate(string: loginFields.username) else { + present(buildInvalidEmailAlertGeneric(), animated: true, completion: nil) + return + } + + configureViewLoading(true) + let service = WordPressComAccountService() + service.isPasswordlessAccount(username: loginFields.username, + success: { [weak self] passwordless in + self?.configureViewLoading(false) + self?.loginFields.meta.passwordless = passwordless + passwordless ? self?.requestAuthenticationLink() : self?.showPasswordOrMagicLinkView() + }, + failure: { [weak self] error in + WordPressAuthenticator.track(.loginFailed, error: error) + WPAuthenticatorLogError(error.localizedDescription) + guard let self = self else { + return + } + self.configureViewLoading(false) + + self.handleLoginError(error) + }) + } + + /// Show the Password entry view. + /// + func showPasswordView() { + guard let vc = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("Failed to navigate to PasswordViewController from GetStartedViewController") + return + } + + vc.source = source + vc.loginFields = loginFields + vc.trackAsPasswordChallenge = false + + navigationController?.pushViewController(vc, animated: true) + } + + /// Show the password or magic link view based on the configuration. + /// + func showPasswordOrMagicLinkView() { + guard let navigationController = navigationController else { + return + } + configureViewLoading(true) + let coordinator = PasswordCoordinator(navigationController: navigationController, + source: source, + loginFields: loginFields, + tracker: tracker, + configuration: configuration) + passwordCoordinator = coordinator + Task { @MainActor [weak self] in + guard let self = self else { return } + await coordinator.start() + self.configureViewLoading(false) + } + } + + /// Handle errors when attempting to log in with an email address + /// + func handleLoginError(_ error: Error) { + let userInfo = (error as NSError).userInfo + let errorCode = userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String + + if configuration.enableSignUp, errorCode == "unknown_user" { + self.sendEmail() + } else if errorCode == "email_login_not_allowed" { + // If we get this error, we know we have a WordPress.com user but their + // email address is flagged as suspicious. They need to login via their + // username instead. + self.showSelfHostedWithError(error) + } else { + let signInError = SignInError(error: error, source: source) ?? error + guard let authenticationDelegate = WordPressAuthenticator.shared.delegate, + authenticationDelegate.shouldHandleError(signInError) else { + displayError(error, sourceTag: sourceTag) + return + } + + /// Hand over control to the host app. + authenticationDelegate.handleError(signInError) { customUI in + // Setting the rightBarButtonItems of the custom UI before pushing the view controller + // and resetting the navigationController's navigationItem after the push seems to be the + // only combination that gets the Help button to show up. + customUI.navigationItem.rightBarButtonItems = self.navigationItem.rightBarButtonItems + self.navigationController?.navigationItem.rightBarButtonItems = self.navigationItem.rightBarButtonItems + + self.navigationController?.pushViewController(customUI, animated: true) + } + } + } + + // MARK: - Send email + + /// Makes the call to request a magic signup link be emailed to the user. + /// + private func sendEmail() { + tracker.set(flow: .signup) + loginFields.meta.emailMagicLinkSource = .signup + + configureSubmitButton(animating: true) + + let service = WordPressComAccountService() + service.requestSignupLink(for: loginFields.username, + success: { [weak self] in + self?.didRequestSignupLink() + self?.configureSubmitButton(animating: false) + + }, failure: { [weak self] (error: Error) in + WPAuthenticatorLogError("Request for signup link email failed.") + + guard let self = self else { + return + } + + self.tracker.track(failure: error.localizedDescription) + self.displayError(error, sourceTag: self.sourceTag) + self.configureSubmitButton(animating: false) + }) + } + + private func didRequestSignupLink() { + guard let vc = SignupMagicLinkViewController.instantiate(from: .unifiedSignup) else { + WPAuthenticatorLogError("Failed to navigate from UnifiedSignupViewController to SignupMagicLinkViewController") + return + } + + vc.loginFields = loginFields + vc.loginFields.restrictToWPCom = true + + navigationController?.pushViewController(vc, animated: true) + } + + /// Makes the call to request a magic authentication link be emailed to the user. + /// + func requestAuthenticationLink() { + loginFields.meta.emailMagicLinkSource = .login + + let email = loginFields.username + guard email.isValidEmail() else { + present(buildInvalidEmailLinkAlert(), animated: true, completion: nil) + return + } + + configureViewLoading(true) + let service = WordPressComAccountService() + service.requestAuthenticationLink(for: email, + jetpackLogin: loginFields.meta.jetpackLogin, + success: { [weak self] in + self?.didRequestAuthenticationLink() + self?.configureViewLoading(false) + + }, failure: { [weak self] (error: Error) in + guard let self = self else { + return + } + + self.tracker.track(failure: error.localizedDescription) + + self.displayError(error, sourceTag: self.sourceTag) + self.configureViewLoading(false) + }) + } + + /// When a magic link successfully sends, navigate the user to the next step. + /// + func didRequestAuthenticationLink() { + guard let vc = LoginMagicLinkViewController.instantiate(from: .unifiedLoginMagicLink) else { + WPAuthenticatorLogError("Failed to navigate to LoginMagicLinkViewController from GetStartedViewController") + return + } + + vc.loginFields = self.loginFields + vc.loginFields.restrictToWPCom = true + navigationController?.pushViewController(vc, animated: true) + } + + /// Build the alert message when the email address is invalid + /// + private func buildInvalidEmailAlertGeneric() -> UIAlertController { + let title = NSLocalizedString("Invalid Email Address", + comment: "Title of an alert letting the user know the email address that they've entered isn't valid") + let message = NSLocalizedString("Please enter a valid email address for a WordPress.com account.", + comment: "An error message.") + + return buildInvalidEmailAlert(title: title, message: message) + } + + /// Build the alert message when the email address is invalid so a link cannot be requested + /// + private func buildInvalidEmailLinkAlert() -> UIAlertController { + let title = NSLocalizedString("Can Not Request Link", + comment: "Title of an alert letting the user know") + let message = NSLocalizedString("A valid email address is needed to mail an authentication link. Please return to the previous screen and provide a valid email address.", + comment: "An error message.") + + return buildInvalidEmailAlert(title: title, message: message) + } + + private func buildInvalidEmailAlert(title: String, message: String) -> UIAlertController { + + let helpActionTitle = NSLocalizedString("Need help?", + comment: "Takes the user to get help") + let okActionTitle = NSLocalizedString("OK", + comment: "Dismisses the alert") + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + alert.addActionWithTitle(helpActionTitle, + style: .cancel, + handler: { _ in + WordPressAuthenticator.shared.delegate?.presentSupportRequest(from: self, sourceTag: .loginEmail) + }) + + alert.addActionWithTitle(okActionTitle, style: .default, handler: nil) + + return alert + } + + /// When password autofill has entered a password on this screen, attempt to login immediately + /// + func attemptAutofillLogin() { + // Even though there was no explicit submit action by the user, we'll interpret + // the credentials selection as such. + tracker.track(click: .submit) + + loginFields.password = hiddenPasswordField?.text ?? "" + loginFields.meta.socialService = nil + displayError(message: "") + validateFormAndLogin() + } + + /// Configures loginFields to log into wordpress.com and navigates to the selfhosted username/password form. + /// Displays the specified error message when the new view controller appears. + /// + func showSelfHostedWithError(_ error: Error) { + loginFields.siteAddress = "https://wordpress.com" + errorToPresent = error + + tracker.track(failure: error.localizedDescription) + + guard let vc = SiteCredentialsViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate to SiteCredentialsViewController from GetStartedViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Navigates to site credentials screen where .org site credentials can be entered + /// + func goToSiteCredentialsScreen() { + guard let vc = SiteCredentialsViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate from GetStartedViewController to SiteCredentialsViewController") + return + } + + vc.loginFields = loginFields.copy() + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Social Button Management + +private extension GetStartedViewController { + + func configureSocialButtons() { + guard let buttonViewController = buttonViewController else { + return + } + + buttonViewController.hideShadowView() + + if configuration.enableSignInWithApple { + buttonViewController.setupTopButtonFor(socialService: .apple) { [weak self] in + self?.appleTapped() + } + } + + buttonViewController.setupButtomButtonFor(socialService: .google) { [weak self] in + self?.googleTapped() + } + + let termsButton = WPStyleGuide.signupTermsButton() + buttonViewController.stackView?.addArrangedSubview(termsButton) + termsButton.addTarget(self, action: #selector(termsTapped), for: .touchUpInside) + } + + func configureButtonViewControllerForSiteCredentialsMode() { + guard let buttonViewController = buttonViewController else { + return + } + + buttonViewController.hideShadowView() + + if configuration.enableSocialLogin { + configureSocialButtons() + + // Setup Sign in with site credentials button + buttonViewController.setupTertiaryButton(attributedTitle: WPStyleGuide.formattedSignInWithSiteCredentialsString(), + isPrimary: false, + accessibilityIdentifier: ButtonConfiguration.SignInWithSiteCredentials.accessibilityIdentifier) { [weak self] in + self?.handleSiteCredentialsButtonTapped() + } + } else { + // Add a "Continue" button here as the `continueButton` at the top will be hidden + // + if showsContinueButtonAtTheBottom { + buttonViewController.setupTopButton(title: ButtonConfiguration.Continue.title, + isPrimary: true, + accessibilityIdentifier: ButtonConfiguration.Continue.accessibilityIdentifier) { [weak self] in + self?.handleSubmitButtonTapped() + } + } + + // Setup Sign in with site credentials button + buttonViewController.setupBottomButton(attributedTitle: WPStyleGuide.formattedSignInWithSiteCredentialsString(), + isPrimary: false, + accessibilityIdentifier: ButtonConfiguration.SignInWithSiteCredentials.accessibilityIdentifier) { [weak self] in + self?.handleSiteCredentialsButtonTapped() + } + } + } + + func configureButtonViewControllerWithoutSocialLogin() { + guard let buttonViewController = buttonViewController else { + return + } + + buttonViewController.hideShadowView() + + if showsContinueButtonAtTheBottom { + // Add a "Continue" button here as the `continueButton` at the top will be hidden + // + buttonViewController.setupTopButton(title: ButtonConfiguration.Continue.title, + isPrimary: true, + accessibilityIdentifier: ButtonConfiguration.Continue.accessibilityIdentifier) { [weak self] in + self?.handleSubmitButtonTapped() + } + } + } + + @objc func appleTapped() { + tracker.track(click: .loginWithApple) + + AppleAuthenticator.sharedInstance.delegate = self + AppleAuthenticator.sharedInstance.showFrom(viewController: self) + } + + @objc func googleTapped() { + tracker.track(click: .loginWithGoogle) + + guard let toVC = GoogleAuthViewController.instantiate(from: .googleAuth) else { + WPAuthenticatorLogError("Failed to navigate to GoogleAuthViewController from GetStartedViewController") + return + } + + navigationController?.pushViewController(toVC, animated: true) + } + + @objc func termsTapped() { + tracker.track(click: .termsOfService) + + UIApplication.shared.open(configuration.wpcomTermsOfServiceURL) + } +} + +// MARK: - SFSafariViewControllerDelegate + +extension GetStartedViewController: SFSafariViewControllerDelegate { + func safariViewControllerDidFinish(_ controller: SFSafariViewController) { + // This will only work when the user taps "Done" in the terms of service screen. + // It won't be executed if the user dismisses the terms of service VC by sliding it out of view. + // Unfortunately I haven't found a way to track that scenario. + // + tracker.track(click: .dismiss) + } +} + +// MARK: - AppleAuthenticatorDelegate + +extension GetStartedViewController: AppleAuthenticatorDelegate { + + func showWPComLogin(loginFields: LoginFields) { + self.loginFields = loginFields + showPasswordView() + } + + func showApple2FA(loginFields: LoginFields) { + self.loginFields = loginFields + signInAppleAccount() + } + + func authFailedWithError(message: String) { + displayErrorAlert(message, sourceTag: .loginApple) + tracker.set(flow: .wpCom) + } + +} + +// MARK: - LoginFacadeDelegate + +extension GetStartedViewController { + + // Used by SIWA when logging with with a passwordless, 2FA account. + // + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + configureViewLoading(false) + socialNeedsMultifactorCode(forUserID: userID, andNonceInfo: nonceInfo) + } + +} + +// MARK: - UITextFieldDelegate + +extension GetStartedViewController: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + tracker.track(click: .selectEmailField) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if canSubmit() { + validateForm() + } + return true + } + +} + +// MARK: - Keyboard Notifications + +extension GetStartedViewController { + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +// MARK: - Button configuration + +private extension GetStartedViewController { + enum ButtonConfiguration { + enum Continue { + static let title = WordPressAuthenticator.shared.displayStrings.continueButtonTitle + static let accessibilityIdentifier = "Get Started Email Continue Button" + } + + enum SignInWithSiteCredentials { + static let accessibilityIdentifier = "Sign in with site credentials Button" + } + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuth.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuth.storyboard new file mode 100644 index 000000000000..38789068fba7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuth.storyboard @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuthViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuthViewController.swift new file mode 100644 index 000000000000..05e1e2575cf8 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleAuthViewController.swift @@ -0,0 +1,165 @@ +import SVProgressHUD + +/// View controller that handles the google authentication flow +/// +class GoogleAuthViewController: LoginViewController { + + // MARK: - Properties + + private var hasShownGoogle = false + @IBOutlet var titleLabel: UILabel? + + override var sourceTag: WordPressSupportSourceTag { + get { + return .wpComAuthWaitingForGoogle + } + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.waitingForGoogleTitle + styleNavigationBar(forUnified: true) + + titleLabel?.text = NSLocalizedString("Waiting for Google to complete…", comment: "Message shown on screen while waiting for Google to finish its signup process.") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + showGoogleScreenIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + if isMovingFromParent { + AuthenticatorAnalyticsTracker.shared.track(click: .dismiss) + } + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + +} + +// MARK: - Private Methods + +private extension GoogleAuthViewController { + + func showGoogleScreenIfNeeded() { + guard !hasShownGoogle else { + return + } + + // Flag this as a social sign in. + loginFields.meta.socialService = .google + + GoogleAuthenticator.sharedInstance.delegate = self + GoogleAuthenticator.sharedInstance.showFrom(viewController: self, loginFields: loginFields) + hasShownGoogle = true + } + + func showLoginErrorView(errorTitle: String, errorDescription: String) { + let socialErrorVC = LoginSocialErrorViewController(title: errorTitle, description: errorDescription) + let socialErrorNav = LoginNavigationController(rootViewController: socialErrorVC) + socialErrorVC.delegate = self + socialErrorVC.loginFields = loginFields + socialErrorVC.modalPresentationStyle = .fullScreen + present(socialErrorNav, animated: true) + } + + func showSignupConfirmationView() { + guard let vc = GoogleSignupConfirmationViewController.instantiate(from: .googleSignupConfirmation) else { + WPAuthenticatorLogError("Failed to navigate from GoogleAuthViewController to GoogleSignupConfirmationViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + +} + +// MARK: - GoogleAuthenticatorDelegate + +extension GoogleAuthViewController: GoogleAuthenticatorDelegate { + + // MARK: - Login + + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + syncWPComAndPresentEpilogue(credentials: credentials) + } + + func googleNeedsMultifactorCode(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let vc = TwoFAViewController.instantiate(from: .twoFA) else { + WPAuthenticatorLogError("Failed to navigate from GoogleAuthViewController to TwoFAViewController") + return + } + + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + + func googleExistingUserNeedsConnection(loginFields: LoginFields) { + self.loginFields = loginFields + + guard let vc = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("Failed to navigate from GoogleAuthViewController to PasswordViewController") + return + } + + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields, unknownUser: Bool) { + self.loginFields = loginFields + + // If login failed because there is no existing account, redirect to signup. + // Otherwise, display the error. + let redirectToSignup = unknownUser && WordPressAuthenticator.shared.configuration.enableSignupWithGoogle + + redirectToSignup ? showSignupConfirmationView() : + showLoginErrorView(errorTitle: errorTitle, errorDescription: errorDescription) + } + + func googleAuthCancelled() { + SVProgressHUD.dismiss() + navigationController?.popViewController(animated: true) + } + + // MARK: - Signup + + func googleFinishedSignup(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + // Here for protocol compliance. + } + + func googleLoggedInInstead(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + // Here for protocol compliance. + } + + func googleSignupFailed(error: Error, loginFields: LoginFields) { + // Here for protocol compliance. + } + +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmation.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmation.storyboard new file mode 100644 index 000000000000..ee7ca90ffd8f --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmation.storyboard @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmationViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmationViewController.swift new file mode 100644 index 000000000000..81109643c2d3 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Google/GoogleSignupConfirmationViewController.swift @@ -0,0 +1,259 @@ +import UIKit + +class GoogleSignupConfirmationViewController: LoginViewController { + + // MARK: - Properties + + @IBOutlet private weak var tableView: UITableView! + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + override var sourceTag: WordPressSupportSourceTag { + get { + return .wpComAuthGoogleSignupConfirmation + } + } + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + removeGoogleWaitingView() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.signUpTitle + styleNavigationBar(forUnified: true) + + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + configureForAccessibility() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tracker.set(flow: .signupWithGoogle) + + if isBeingPresentedInAnyWay { + tracker.track(step: .start) + } else { + tracker.set(step: .start) + } + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Override the title on 'submit' button + /// + override func localizePrimaryButton() { + submitButton?.setTitle(WordPressAuthenticator.shared.displayStrings.createAccountButtonTitle, for: .normal) + } + + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + if errorMessage != message { + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } + +} + +// MARK: - UITableViewDataSource + +extension GoogleSignupConfirmationViewController: UITableViewDataSource { + + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - Private Extension + +private extension GoogleSignupConfirmationViewController { + + // MARK: - Button Handling + + @IBAction func handleSubmit() { + tracker.track(click: .submit) + tracker.track(click: .createAccount) + + configureSubmitButton(animating: true) + GoogleAuthenticator.sharedInstance.delegate = self + GoogleAuthenticator.sharedInstance.createGoogleAccount(loginFields: loginFields) + } + + // MARK: - Table Management + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.gravatarEmail, .instructions] + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username) + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.googleSignupInstructions, style: .body) + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Sets up accessibility elements in the order which they should be read aloud + /// and chooses which element to focus on at the beginning. + /// + func configureForAccessibility() { + view.accessibilityElements = [ + tableView as Any, + submitButton as Any + ] + + UIAccessibility.post(notification: .screenChanged, argument: tableView) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case gravatarEmail + case instructions + case errorMessage + + var reuseIdentifier: String { + switch self { + case .gravatarEmail: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions, .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + } + } + } + +} + +// MARK: - GoogleAuthenticatorDelegate + +extension GoogleSignupConfirmationViewController: GoogleAuthenticatorDelegate { + + // MARK: - Signup + + func googleFinishedSignup(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + showSignupEpilogue(for: credentials) + } + + func googleLoggedInInstead(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + self.loginFields = loginFields + showLoginEpilogue(for: credentials) + } + + func googleSignupFailed(error: Error, loginFields: LoginFields) { + configureSubmitButton(animating: false) + self.loginFields = loginFields + displayError(message: error.localizedDescription, moveVoiceOverFocus: true) + } + + // MARK: - Login + + func googleFinishedLogin(credentials: AuthenticatorCredentials, loginFields: LoginFields) { + // Here for protocol compliance. + } + + func googleNeedsMultifactorCode(loginFields: LoginFields) { + // Here for protocol compliance. + } + + func googleExistingUserNeedsConnection(loginFields: LoginFields) { + // Here for protocol compliance. + } + + func googleLoginFailed(errorTitle: String, errorDescription: String, loginFields: LoginFields, unknownUser: Bool) { + // Here for protocol compliance. + } + + func googleAuthCancelled() { + // Here for protocol compliance. + } + +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLink.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLink.storyboard new file mode 100644 index 000000000000..9e7fdb1fb5ae --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLink.storyboard @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLinkViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLinkViewController.swift new file mode 100644 index 000000000000..10a81f2e97fc --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/LoginMagicLinkViewController.swift @@ -0,0 +1,180 @@ +import UIKit + +/// Unified LoginMagicLinkViewController: login to .com with a magic link +/// +final class LoginMagicLinkViewController: LoginViewController { + + // MARK: Properties + + @IBOutlet private weak var tableView: UITableView! + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginMagicLink + } + } + + // MARK: - Actions + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .openEmailClient) + tracker.track(step: .emailOpened) + + let linkMailPresenter = LinkMailPresenter(emailAddress: loginFields.username) + let appSelector = AppSelector(sourceView: sender) + linkMailPresenter.presentEmailClients(on: self, appSelector: appSelector) + } + + // MARK: - View lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + + // Store default margin, and size table for the view. + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tracker.set(flow: .loginWithMagicLink) + + if isMovingToParent { + tracker.track(step: .magicLinkRequested) + } else { + tracker.set(step: .magicLinkRequested) + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(true) + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Override the title on 'submit' button + /// + override func localizePrimaryButton() { + submitButton?.setTitle(WordPressAuthenticator.shared.displayStrings.openMailButtonTitle, for: .normal) + submitButton?.accessibilityIdentifier = "Open Mail Button" + } +} + +// MARK: - UITableViewDataSource +extension LoginMagicLinkViewController: UITableViewDataSource { + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - Private Methods +private extension LoginMagicLinkViewController { + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.persona, .instructions, .checkSpam] + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell where row == .persona: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .checkSpam: + configureCheckSpamLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username) + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.openMailLoginInstructions, style: .body) + } + + /// Configure the "Check spam" cell. + /// + func configureCheckSpamLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.checkSpamInstructions, style: .body) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case persona + case instructions + case checkSpam + + var reuseIdentifier: String { + switch self { + case .persona: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions, .checkSpam: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.swift new file mode 100644 index 000000000000..5aea431582a6 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.swift @@ -0,0 +1,158 @@ +import UIKit +import WordPressUI + +final class MagicLinkRequestedViewController: LoginViewController { + + // MARK: Properties + + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var subtitleLabel: UILabel! + @IBOutlet private weak var emailLabel: UILabel! + @IBOutlet private weak var cannotFindEmailLabel: UILabel! + @IBOutlet private weak var buttonContainerView: UIView! + @IBOutlet private weak var loginWithPasswordButton: UIButton! + + private let email: String + private let loginWithPassword: () -> Void + + private lazy var buttonViewController: NUXButtonViewController = .instance() + + init(email: String, loginWithPassword: @escaping () -> Void) { + self.email = email + self.loginWithPassword = loginWithPassword + super.init(nibName: "MagicLinkRequestedViewController", bundle: WordPressAuthenticator.bundle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var sourceTag: WordPressSupportSourceTag { + .wpComLoginMagicLinkAutoRequested + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + + setupButtons() + setupTitleLabel() + setupSubtitleLabel() + setupEmailLabel() + setupCannotFindEmailLabel() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tracker.set(flow: .loginWithMagicLink) + + if isBeingPresentedInAnyWay { + tracker.track(step: .magicLinkAutoRequested) + } else { + tracker.set(step: .magicLinkAutoRequested) + } + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + return super.styleBackground() + } + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } +} + +private extension MagicLinkRequestedViewController { + func setupButtons() { + setupContinueMailButton() + setupLoginWithPasswordButton() + } + + /// Configures the primary button using the shared NUXButton style without a Storyboard. + func setupContinueMailButton() { + buttonViewController.setupTopButton(title: WordPressAuthenticator.shared.displayStrings.openMailButtonTitle, isPrimary: true, onTap: { [weak self] in + guard let self = self else { return } + guard let topButton = self.buttonViewController.topButton else { + return + } + self.openMail(sender: topButton) + }) + buttonViewController.move(to: self, into: buttonContainerView) + } + + /// Unfortunately, the plain text button style is not available in `NUXButton` as it currently supports primary or secondary. + /// The plain text button is configured manually here. + func setupLoginWithPasswordButton() { + loginWithPasswordButton.setTitle(Localization.loginWithPasswordAction, for: .normal) + loginWithPasswordButton.applyLinkButtonStyle() + loginWithPasswordButton.on(.touchUpInside) { [weak self] _ in + self?.loginWithPassword() + } + } + + func setupTitleLabel() { + titleLabel.text = Localization.title + titleLabel.font = WPStyleGuide.mediumWeightFont(forStyle: .title3) + titleLabel.textColor = WordPressAuthenticator.shared.unifiedStyle?.textColor + titleLabel.numberOfLines = 0 + } + + func setupSubtitleLabel() { + subtitleLabel.text = Localization.subtitle + subtitleLabel.font = WPStyleGuide.fontForTextStyle(.body) + subtitleLabel.textColor = WordPressAuthenticator.shared.unifiedStyle?.textColor + subtitleLabel.numberOfLines = 0 + } + + func setupEmailLabel() { + emailLabel.text = email + emailLabel.numberOfLines = 0 + emailLabel.font = WPStyleGuide.fontForTextStyle(.body, fontWeight: .bold) + emailLabel.textColor = WordPressAuthenticator.shared.unifiedStyle?.textColor + } + + func setupCannotFindEmailLabel() { + cannotFindEmailLabel.text = Localization.cannotFindMailLoginInstructions + cannotFindEmailLabel.numberOfLines = 0 + cannotFindEmailLabel.font = WPStyleGuide.fontForTextStyle(.footnote) + cannotFindEmailLabel.textColor = WordPressAuthenticator.shared.unifiedStyle?.textSubtleColor + } +} + +private extension MagicLinkRequestedViewController { + func openMail(sender: UIView) { + tracker.track(click: .openEmailClient) + tracker.track(step: .emailOpened) + + let linkMailPresenter = LinkMailPresenter(emailAddress: email) + let appSelector = AppSelector(sourceView: sender) + linkMailPresenter.presentEmailClients(on: self, appSelector: appSelector) + } +} + +private extension MagicLinkRequestedViewController { + enum Localization { + static let cannotFindMailLoginInstructions = NSLocalizedString("If you can’t find the email, please check your junk or spam email folder", + comment: "The instructions text about not being able to find the magic link email.") + static let title = NSLocalizedString("Check your email on this device!", + comment: "The title text on the magic link requested screen.") + static let subtitle = NSLocalizedString("We just sent a magic link to", + comment: "The subtitle text on the magic link requested screen followed by the email address.") + static let loginWithPasswordAction = NSLocalizedString("Use password to sign in", + comment: "The button title text for logging in with WP.com password instead of magic link.") + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.xib new file mode 100644 index 000000000000..fdcd6f348132 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequestedViewController.xib @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequester.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequester.swift new file mode 100644 index 000000000000..efb7409a98ab --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Login/MagicLinkRequester.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Encapsulates the async request for a magic link and email validation for use cases that send a magic link. +struct MagicLinkRequester { + /// Makes the call to request a magic authentication link be emailed to the user if possible. + func requestMagicLink(email: String, jetpackLogin: Bool) async -> Result { + await withCheckedContinuation { continuation in + guard email.isValidEmail() else { + return continuation.resume(returning: .failure(MagicLinkRequestError.invalidEmail)) + } + + let service = WordPressComAccountService() + service.requestAuthenticationLink(for: email, + jetpackLogin: jetpackLogin, + success: { + continuation.resume(returning: .success(())) + }, failure: { error in + continuation.resume(returning: .failure(error)) + }) + } + } +} + +extension MagicLinkRequester { + enum MagicLinkRequestError: Error { + case invalidEmail + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/Password.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/Password.storyboard new file mode 100644 index 000000000000..85cfde6f2711 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/Password.storyboard @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordCoordinator.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordCoordinator.swift new file mode 100644 index 000000000000..1348f84e3f64 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordCoordinator.swift @@ -0,0 +1,68 @@ +/// Coordinates the navigation after entering WP.com username. +/// Based on the configuration, it could automatically send a magic link and proceed the magic link requested screen on success and fall back to password. +@MainActor +final class PasswordCoordinator { + private weak var navigationController: UINavigationController? + private let source: SignInSource? + private let loginFields: LoginFields + private let tracker: AuthenticatorAnalyticsTracker + private let configuration: WordPressAuthenticatorConfiguration + + init(navigationController: UINavigationController, + source: SignInSource?, + loginFields: LoginFields, + tracker: AuthenticatorAnalyticsTracker, + configuration: WordPressAuthenticatorConfiguration) { + self.navigationController = navigationController + self.source = source + self.loginFields = loginFields + self.tracker = tracker + self.configuration = configuration + } + + func start() async { + if configuration.isWPComMagicLinkPreferredToPassword { + let result = await requestMagicLink() + switch result { + case .success: + loginFields.restrictToWPCom = true + showMagicLinkRequested() + case .failure(let error): + // When magic link request fails, falls back to the password flow. + showPassword() + tracker.track(failure: error.localizedDescription) + } + } else { + showPassword() + } + } +} + +private extension PasswordCoordinator { + /// Makes the call to request a magic authentication link be emailed to the user. + func requestMagicLink() async -> Result { + loginFields.meta.emailMagicLinkSource = .login + return await MagicLinkRequester().requestMagicLink(email: loginFields.username, jetpackLogin: loginFields.meta.jetpackLogin) + } + + /// After a magic link is successfully sent, navigates the user to the requested screen. + func showMagicLinkRequested() { + let vc = MagicLinkRequestedViewController(email: loginFields.username) { [weak self] in + self?.showPassword() + } + navigationController?.pushViewController(vc, animated: true) + } + + /// Navigates the user to enter WP.com password. + func showPassword() { + guard let vc = PasswordViewController.instantiate(from: .password) else { + return WPAuthenticatorLogError("Failed to navigate to PasswordViewController from GetStartedViewController") + } + + vc.source = source + vc.loginFields = loginFields + vc.trackAsPasswordChallenge = false + + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordViewController.swift new file mode 100644 index 000000000000..cc6bdddf3bdd --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Password/PasswordViewController.swift @@ -0,0 +1,617 @@ +import UIKit +import WordPressKit + +/// PasswordViewController: view to enter WP account password. +/// +class PasswordViewController: LoginViewController { + + // MARK: - Properties + + @IBOutlet private weak var tableView: UITableView! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + @IBOutlet private weak var secondaryButton: NUXButton! + + private weak var passwordField: UITextField? + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + private var loginLinkCell: TextLinkButtonTableViewCell? + + private let isMagicLinkShownAsSecondaryAction: Bool = WordPressAuthenticator.shared.configuration.isWPComMagicLinkShownAsSecondaryActionOnPasswordScreen + + private let configuration = WordPressAuthenticator.shared.configuration + + /// Depending on where we're coming from, this screen needs to track a password challenge + /// (if logging on with a Social account) or not (if logging in through WP.com). + /// + var trackAsPasswordChallenge = true + + var source: SignInSource? + + override var loginFields: LoginFields { + didSet { + loginFields.password = "" + } + } + + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginWPComPassword + } + } + + // Required for `NUXKeyboardResponder` but unused here. + var verticalCenterConstraint: NSLayoutConstraint? + + // MARK: - View + + override func viewDidLoad() { + super.viewDidLoad() + + removeGoogleWaitingView() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + configureLoginWithMagicLinkButton() + localizePrimaryButton() + registerTableViewCells() + loadRows() + configureForAccessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + loginFields.meta.userIsDotCom = true + configureSubmitButton(animating: false) + loginLinkCell?.enableButton(true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if trackAsPasswordChallenge { + if isMovingToParent { + tracker.track(step: .passwordChallenge) + } else { + tracker.set(step: .passwordChallenge) + } + } else { + tracker.set(flow: isMagicLinkShownAsSecondaryAction ? .loginWithPasswordWithMagicLinkEmphasis : .loginWithPassword) + + if isMovingToParent { + tracker.track(step: .start) + } else { + tracker.set(step: .start) + } + } + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + + configureViewForEditingIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + // MARK: - Overrides + + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? + WordPressAuthenticator.shared.style.statusBarStyle + } + + override func configureViewLoading(_ loading: Bool) { + super.configureViewLoading(loading) + passwordField?.isEnabled = !loading + } + + override func displayRemoteError(_ error: Error) { + configureViewLoading(false) + + if let source = source, loginFields.meta.userIsDotCom { + let passwordError = SignInError.invalidWPComPassword(source: source) + if authenticationDelegate.shouldHandleError(passwordError) { + authenticationDelegate.handleError(passwordError) { _ in + // No custom navigation is expected in this case. + } + } + } + + if let oauthError = error as? WordPressComOAuthError, case let .endpointError(failure) = oauthError, failure.kind == .invalidRequest { + // The only difference between an incorrect password error and exceeded login limit error + // is the actual error string. So check for "password" in the error string, and show the custom + // error message. Otherwise, show the actual response error. + var displayMessage: String { + // swiftlint:disable localization_comment + if let msg = failure.localizedErrorMessage, msg.contains(NSLocalizedString("password", comment: "")) { + // swiftlint:enable localization_comment + return NSLocalizedString("It seems like you've entered an incorrect password. Want to give it another try?", comment: "An error message shown when a wpcom user provides the wrong password.") + } + if let msg = failure.localizedErrorMessage { + return msg + } + return oauthError.localizedDescription + } + displayError(message: displayMessage, moveVoiceOverFocus: true) + } else { + displayError(error, sourceTag: sourceTag) + } + } + + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + // The reason why this check is necessary is that we're calling this method + // with an empty error message when setting up the VC. We don't want to track + // an empty error when that happens. + if !message.isEmpty { + tracker.track(failure: message) + } + + configureViewLoading(false) + + if errorMessage != message { + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } + + override func validateFormAndLogin() { + view.endEditing(true) + displayError(message: "", moveVoiceOverFocus: true) + + // Is everything filled out? + if !loginFields.validateFieldsPopulatedForSignin() { + let errorMsg = Localization.missingInfoError + displayError(message: errorMsg, moveVoiceOverFocus: true) + + return + } + + configureViewLoading(true) + + loginFacade.signIn(with: loginFields) + } +} + +// MARK: - LoginFacadeDelegate + +extension PasswordViewController { + // Used when the account has support for security keys. + // + func needsMultifactorCode(forUserID userID: Int, andNonceInfo nonceInfo: SocialLogin2FANonceInfo) { + configureViewLoading(false) + socialNeedsMultifactorCode(forUserID: userID, andNonceInfo: nonceInfo) + } +} + +// MARK: - Validation and Continue + +private extension PasswordViewController { + + // MARK: - Button Actions + + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .submit) + + configureViewLoading(true) + validateForm() + } + + func validateForm() { + validateFormAndLogin() + } +} + +// MARK: - UITextFieldDelegate + +extension PasswordViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if enableSubmit(animating: false) { + validateForm() + } + return true + } + +} + +// MARK: - UITableViewDataSource + +extension PasswordViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + return cell + } + +} + +// MARK: - Keyboard Notifications + +extension PasswordViewController: NUXKeyboardResponder { + + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } + +} + +// MARK: - Magic Link + +private extension PasswordViewController { + func configureLoginWithMagicLinkButton() { + if isMagicLinkShownAsSecondaryAction { + secondaryButton.setTitle(Localization.loginWithMagicLink, for: .normal) + secondaryButton.accessibilityIdentifier = AccessibilityIdentifier.loginWithMagicLink + secondaryButton.on(.touchUpInside) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self = self else { return } + self.secondaryButton.isEnabled = false + await self.loginWithMagicLink() + self.secondaryButton.isEnabled = true + } + } + } else { + secondaryButton.isHidden = true + } + } + + func loginWithMagicLink() async { + tracker.track(click: .requestMagicLink) + loginFields.meta.emailMagicLinkSource = .login + + updateLoadingUI(isRequestingMagicLink: true) + let result = await MagicLinkRequester().requestMagicLink(email: loginFields.username, jetpackLogin: loginFields.meta.jetpackLogin) + switch result { + case .success: + didRequestAuthenticationLink() + case .failure(let error): + switch error { + case MagicLinkRequester.MagicLinkRequestError.invalidEmail: + WPAuthenticatorLogError("Attempted to request authentication link, but the email address did not appear valid.") + let alert = buildInvalidEmailAlert() + present(alert, animated: true, completion: nil) + default: + tracker.track(failure: error.localizedDescription) + displayError(error, sourceTag: sourceTag) + } + } + updateLoadingUI(isRequestingMagicLink: false) + } + + func updateLoadingUI(isRequestingMagicLink: Bool) { + if isRequestingMagicLink { + if isMagicLinkShownAsSecondaryAction { + submitButton?.isEnabled = false + secondaryButton.showActivityIndicator(true) + } else { + configureViewLoading(true) + } + } else { + if isMagicLinkShownAsSecondaryAction { + submitButton?.isEnabled = true + secondaryButton.showActivityIndicator(false) + } else { + configureViewLoading(false) + } + } + } +} + +// MARK: - Table Management + +private extension PasswordViewController { + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib(), + TextFieldTableViewCell.reuseIdentifier: TextFieldTableViewCell.loadNib(), + TextLinkButtonTableViewCell.reuseIdentifier: TextLinkButtonTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.gravatarEmail] + + // Instructions only for social accounts and simplified WPCom login flow + if loginFields.meta.socialService != nil || + configuration.wpcomPasswordInstructions != nil { + rows.append(.instructions) + } + + rows.append(.password) + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + + rows.append(.forgotPassword) + + if !isMagicLinkShownAsSecondaryAction { + rows.append(.sendMagicLink) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextFieldTableViewCell where row == .password: + configurePasswordTextField(cell) + case let cell as TextLinkButtonTableViewCell where row == .forgotPassword: + configureForgotPasswordButton(cell) + case let cell as TextLinkButtonTableViewCell where row == .sendMagicLink: + configureSendMagicLinkButton(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username, hasBorders: configuration.emphasizeEmailForWPComPassword) + + cell.onChangeSelectionHandler = { [weak self] textfield in + // The email can only be changed via a password manager. + // In this case, don't update username for social accounts. + // This prevents inadvertent account linking. + if self?.loginFields.meta.socialService != nil { + cell.updateEmailAddress(self?.loginFields.username) + } else { + self?.loginFields.username = textfield.nonNilTrimmedText() + self?.loginFields.emailAddress = textfield.nonNilTrimmedText() + } + + self?.configureSubmitButton(animating: false) + } + } + + /// Configure the instruction cell for social accounts or simplified login. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + let displayStrings = WordPressAuthenticator.shared.displayStrings + let instructions: String? = { + if let service = loginFields.meta.socialService { + return (service == .google) ? displayStrings.googlePasswordInstructions : + displayStrings.applePasswordInstructions + } + return configuration.wpcomPasswordInstructions + }() + + guard let instructions = instructions else { + return + } + + cell.configureLabel(text: instructions) + } + + /// Configure the password textfield cell. + /// + func configurePasswordTextField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .password, + placeholder: WordPressAuthenticator.shared.displayStrings.passwordPlaceholder) + + // Save a reference to the first textField so it can becomeFirstResponder. + passwordField = cell.textField + cell.textField.delegate = self + + cell.onChangeSelectionHandler = { [weak self] textfield in + self?.loginFields.password = textfield.nonNilTrimmedText() + self?.configureSubmitButton(animating: false) + } + + SigninEditingState.signinEditingStateActive = true + + if UIAccessibility.isVoiceOverRunning { + // Quiet repetitive VoiceOver elements. + passwordField?.placeholder = nil + } + } + + /// Configure the forgot password link cell. + /// + func configureForgotPasswordButton(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.resetPasswordButtonTitle, + accessibilityTrait: .link, + showBorder: true) + cell.actionHandler = { [weak self] in + guard let self = self else { + return + } + + self.tracker.track(click: .forgottenPassword) + + // If information is currently processing, ignore button tap. + guard self.enableSubmit(animating: false) else { + return + } + + WordPressAuthenticator.openForgotPasswordURL(self.loginFields) + } + } + + /// Configure the "send magic link" cell. + /// + func configureSendMagicLinkButton(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.getLoginLinkButtonTitle, + accessibilityTrait: .link, + showBorder: true) + cell.accessibilityIdentifier = AccessibilityIdentifier.loginWithMagicLink + + // Save reference to the login link cell so it can be enabled/disabled. + loginLinkCell = cell + + cell.actionHandler = { [weak self] in + guard let self = self else { + return + } + + cell.enableButton(false) + + Task { @MainActor [weak self] in + await self?.loginWithMagicLink() + } + } + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + cell.accessibilityIdentifier = "Password Error" + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Configure the view for an editing state. + /// + func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + passwordField?.becomeFirstResponder() + } + } + + /// Sets up accessibility elements in the order which they should be read aloud + /// and chooses which element to focus on at the beginning. + /// + func configureForAccessibility() { + view.accessibilityElements = [ + passwordField as Any, + tableView as Any, + submitButton as Any + ] + + if isMagicLinkShownAsSecondaryAction { + view.accessibilityElements?.append(secondaryButton as Any) + } + + UIAccessibility.post(notification: .screenChanged, argument: passwordField) + } + + /// When a magic link successfully sends, navigate the user to the next step. + /// + func didRequestAuthenticationLink() { + guard let vc = LoginMagicLinkViewController.instantiate(from: .unifiedLoginMagicLink) else { + WPAuthenticatorLogError("Failed to navigate to LoginMagicLinkViewController") + return + } + + vc.loginFields = self.loginFields + vc.loginFields.restrictToWPCom = true + navigationController?.pushViewController(vc, animated: true) + } + + /// Build the alert message when the email address is invalid. + /// + func buildInvalidEmailAlert() -> UIAlertController { + let title = NSLocalizedString("Can Not Request Link", + comment: "Title of an alert letting the user know") + let message = NSLocalizedString("A valid email address is needed to mail an authentication link. Please return to the previous screen and provide a valid email address.", + comment: "An error message.") + let helpActionTitle = NSLocalizedString("Need help?", + comment: "Takes the user to get help") + let okActionTitle = NSLocalizedString("OK", + comment: "Dismisses the alert") + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + alert.addActionWithTitle(helpActionTitle, + style: .cancel, + handler: { _ in + WordPressAuthenticator.shared.delegate?.presentSupportRequest(from: self, sourceTag: .loginEmail) + }) + + alert.addActionWithTitle(okActionTitle, style: .default, handler: nil) + + return alert + } + + /// Rows listed in the order they were created. + /// + enum Row { + case gravatarEmail + case instructions + case password + case forgotPassword + case sendMagicLink + case errorMessage + + var reuseIdentifier: String { + switch self { + case .gravatarEmail: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions: + return TextLabelTableViewCell.reuseIdentifier + case .password: + return TextFieldTableViewCell.reuseIdentifier + case .sendMagicLink: + return TextLinkButtonTableViewCell.reuseIdentifier + case .forgotPassword: + return TextLinkButtonTableViewCell.reuseIdentifier + case .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} + +private extension PasswordViewController { + /// Localization constants + /// + enum Localization { + static let missingInfoError = NSLocalizedString("Please fill out all the fields", + comment: "A short prompt asking the user to properly fill out all login fields.") + static let loginWithMagicLink = NSLocalizedString("Or log in with magic link", + comment: "The button title for a secondary call-to-action button on the password screen. When the user wants to try sending a magic link instead of entering a password.") + } + + enum AccessibilityIdentifier { + static let loginWithMagicLink = "Get Login Link Button" + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.swift new file mode 100644 index 000000000000..f95c71ddac51 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.swift @@ -0,0 +1,84 @@ +import UIKit + +/// GravatarEmailTableViewCell: Gravatar image + Email address in a UITableViewCell. +/// +class GravatarEmailTableViewCell: UITableViewCell { + + /// Private properties + /// + @IBOutlet private weak var gravatarImageView: UIImageView? + @IBOutlet private weak var emailLabel: UITextField? + @IBOutlet private var containerView: UIView! + + @IBOutlet private var containerViewMargins: [NSLayoutConstraint]! + @IBOutlet private var gravatarImageViewSizeConstraints: [NSLayoutConstraint]! + + private let gridiconSize = CGSize(width: 48, height: 48) + private let girdiconSmallSize = CGSize(width: 32, height: 32) + + /// Public properties + /// + public static let reuseIdentifier = "GravatarEmailTableViewCell" + public var onChangeSelectionHandler: ((_ sender: UITextField) -> Void)? + + /// Public Methods + /// + public func configure(withEmail email: String?, andPlaceholder placeholderImage: UIImage? = nil, hasBorders: Bool = false) { + gravatarImageView?.tintColor = WordPressAuthenticator.shared.unifiedStyle?.borderColor ?? WordPressAuthenticator.shared.style.primaryNormalBorderColor + emailLabel?.textColor = WordPressAuthenticator.shared.unifiedStyle?.gravatarEmailTextColor ?? WordPressAuthenticator.shared.unifiedStyle?.textSubtleColor ?? WordPressAuthenticator.shared.style.subheadlineColor + emailLabel?.font = UIFont.preferredFont(forTextStyle: .body) + emailLabel?.text = email + + let gridicon: UIImage = .gridicon(.userCircle, size: hasBorders ? girdiconSmallSize : gridiconSize) + + guard let email = email, + email.isValidEmail() else { + gravatarImageView?.image = gridicon + return + } + + gravatarImageView?.downloadGravatarWithEmail(email, placeholderImage: placeholderImage ?? gridicon) + + gravatarImageViewSizeConstraints.forEach { constraint in + constraint.constant = gridicon.size.width + } + + let margin: CGFloat = hasBorders ? 16 : 0 + containerViewMargins.forEach { constraint in + constraint.constant = margin + } + + containerView.layer.borderWidth = hasBorders ? 1 : 0 + containerView.layer.cornerRadius = hasBorders ? 8 : 0 + containerView.layer.borderColor = hasBorders ? UIColor.systemGray3.cgColor : UIColor.clear.cgColor + } + + func updateEmailAddress(_ email: String?) { + emailLabel?.text = email + } + +} + +// MARK: - Password Manager Handling + +private extension GravatarEmailTableViewCell { + + // MARK: - All Password Managers + + /// Call the handler when the text field changes. + /// + /// - Note: we have to manually add an action to the textfield + /// because the delegate method `textFieldDidChangeSelection(_ textField: UITextField)` + /// is only available to iOS 13+. When we no longer support iOS 12, + /// `textFieldDidChangeSelection`, and `onChangeSelectionHandler` can + /// be deleted in favor of adding the delegate method to view controllers. + /// + @IBAction func textFieldDidChangeSelection() { + guard let emailTextField = emailLabel else { + return + } + + onChangeSelectionHandler?(emailTextField) + } + +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.xib new file mode 100644 index 000000000000..49db946227c5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/GravatarEmailTableViewCell.xib @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.swift new file mode 100644 index 000000000000..6a03da34f6e4 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.swift @@ -0,0 +1,233 @@ +import UIKit + +/// TextFieldTableViewCell: a textfield with a custom border line in a UITableViewCell. +/// +final class TextFieldTableViewCell: UITableViewCell { + + /// Private properties. + /// + @IBOutlet private weak var borderView: UIView! + @IBOutlet private weak var borderWidth: NSLayoutConstraint! + private var secureTextEntryToggle: UIButton? + private var secureTextEntryImageVisible: UIImage? + private var secureTextEntryImageHidden: UIImage? + private var textfieldStyle: TextFieldStyle = .url + + /// Register an action for the SiteAddress URL textfield. + /// - Note: we have to manually add an action to the textfield + /// because the delegate method `textFieldDidChangeSelection(_ textField: UITextField)` + /// is only available to iOS 13+. When we no longer support iOS 12, + /// `registerTextFieldAction`, `textFieldDidChangeSelection`, and `onChangeSelectionHandler` can + /// be deleted in favor of adding the delegate method to SiteAddressViewController. + @IBAction func registerTextFieldAction() { + onChangeSelectionHandler?(textField) + } + + /// Public properties. + /// + @IBOutlet public weak var textField: UITextField! // public so it can be the first responder + @IBInspectable public var showSecureTextEntryToggle: Bool = false { + didSet { + configureSecureTextEntryToggle() + } + } + + public var onChangeSelectionHandler: ((_ sender: UITextField) -> Void)? + public static let reuseIdentifier = "TextFieldTableViewCell" + + override func awakeFromNib() { + super.awakeFromNib() + styleBorder() + setCommonTextFieldStyles() + } + + /// Configures the textfield for URL, username, or entering a password. + /// - Parameter style: changes the textfield behavior and appearance. + /// - Parameter placeholder: the placeholder text, if any. + /// - Parameter text: the field text, if any. + /// + public func configure(withStyle style: TextFieldStyle = .url, placeholder: String? = nil, text: String? = nil) { + textfieldStyle = style + applyTextFieldStyle(style) + textField.placeholder = placeholder + textField.text = text + } + + override func prepareForReuse() { + super.prepareForReuse() + + textField.keyboardType = .default + textField.returnKeyType = .default + setSecureTextEntry(false) + showSecureTextEntryToggle = false + textField.rightView = nil + textField.accessibilityLabel = nil + textField.accessibilityIdentifier = nil + } +} + +// MARK: - Private methods +private extension TextFieldTableViewCell { + + /// Style the bottom cell border, called borderView. + /// + func styleBorder() { + let borderColor = WordPressAuthenticator.shared.unifiedStyle?.borderColor ?? WordPressAuthenticator.shared.style.primaryNormalBorderColor + borderView.backgroundColor = borderColor + borderWidth.constant = WPStyleGuide.hairlineBorderWidth + } + + /// Apply common keyboard traits and font styles. + /// + func setCommonTextFieldStyles() { + textField.font = UIFont.preferredFont(forTextStyle: .body) + textField.autocorrectionType = .no + } + + /// Sets the textfield keyboard type and applies common traits. + /// - note: Don't assign first responder here. It's too early in the view lifecycle. + /// + func applyTextFieldStyle(_ style: TextFieldStyle) { + switch style { + case .url: + textField.keyboardType = .URL + textField.returnKeyType = .continue + registerTextFieldAction() + textField.accessibilityLabel = Constants.siteAddress + textField.accessibilityIdentifier = Constants.siteAddressID + case .username: + textField.keyboardType = .default + textField.returnKeyType = .next + textField.accessibilityLabel = Constants.username + textField.accessibilityIdentifier = Constants.usernameID + case .password: + textField.keyboardType = .default + textField.returnKeyType = .continue + setSecureTextEntry(true) + showSecureTextEntryToggle = true + configureSecureTextEntryToggle() + textField.accessibilityLabel = Constants.password + textField.accessibilityIdentifier = Constants.passwordID + case .numericCode: + textField.keyboardType = .numberPad + textField.returnKeyType = .continue + textField.accessibilityLabel = Constants.otp + textField.accessibilityIdentifier = Constants.otpID + case .email: + textField.keyboardType = .emailAddress + textField.returnKeyType = .continue + textField.textContentType = .username // So the password autofill appears on the keyboard + textField.accessibilityLabel = Constants.email + textField.accessibilityIdentifier = Constants.emailID + } + } + + /// Call the handler when the textfield changes. + /// + @objc func textFieldDidChangeSelection() { + onChangeSelectionHandler?(textField) + } +} + +// MARK: - Secure Text Entry +/// Methods ported from WPWalkthroughTextField.h/.m +/// +private extension TextFieldTableViewCell { + + /// Build the show / hide icon in the textfield. + /// + func configureSecureTextEntryToggle() { + guard showSecureTextEntryToggle else { + return + } + + secureTextEntryImageVisible = UIImage.gridicon(.visible) + secureTextEntryImageHidden = UIImage.gridicon(.notVisible) + + secureTextEntryToggle = UIButton(type: .custom) + secureTextEntryToggle?.clipsToBounds = true + // The icon should match the border color. + let tintColor = WordPressAuthenticator.shared.unifiedStyle?.borderColor ?? WordPressAuthenticator.shared.style.primaryNormalBorderColor + secureTextEntryToggle?.tintColor = tintColor + + secureTextEntryToggle?.addTarget(self, + action: #selector(secureTextEntryToggleAction), + for: .touchUpInside) + + updateSecureTextEntryToggleImage() + updateSecureTextEntryForAccessibility() + textField.rightView = secureTextEntryToggle + textField.rightViewMode = .always + } + + func setSecureTextEntry(_ secureTextEntry: Bool) { + textField.font = UIFont.preferredFont(forTextStyle: .body) + + textField.isSecureTextEntry = secureTextEntry + updateSecureTextEntryToggleImage() + updateSecureTextEntryForAccessibility() + } + + @objc func secureTextEntryToggleAction(_ sender: Any) { + textField.isSecureTextEntry = !textField.isSecureTextEntry + + // Save and re-apply the current selection range to save the cursor position + let currentTextRange = textField.selectedTextRange + textField.becomeFirstResponder() + textField.selectedTextRange = currentTextRange + updateSecureTextEntryToggleImage() + updateSecureTextEntryForAccessibility() + } + + func updateSecureTextEntryToggleImage() { + let image = textField.isSecureTextEntry ? secureTextEntryImageHidden : secureTextEntryImageVisible + secureTextEntryToggle?.setImage(image, for: .normal) + secureTextEntryToggle?.sizeToFit() + } + + func updateSecureTextEntryForAccessibility() { + secureTextEntryToggle?.accessibilityLabel = Constants.showPassword + secureTextEntryToggle?.accessibilityIdentifier = Constants.showPassword + secureTextEntryToggle?.accessibilityValue = textField.isSecureTextEntry ? Constants.passwordHidden : Constants.passwordShown + } +} + +// MARK: - Constants +extension TextFieldTableViewCell { + + /// TextField configuration options. + /// + enum TextFieldStyle { + case url + case username + case password + case numericCode + case email + } + + struct Constants { + /// Accessibility Hints + /// + static let passwordHidden = NSLocalizedString("Hidden", + comment: "Accessibility value if login page's password field is hiding the password (i.e. with asterisks).") + static let passwordShown = NSLocalizedString("Shown", + comment: "Accessibility value if login page's password field is displaying the password.") + static let showPassword = NSLocalizedString("Show password", + comment: "Accessibility label for the 'Show password' button in the login page's password field.") + static let siteAddress = NSLocalizedString("Site address", + comment: "Accessibility label of the site address field shown when adding a self-hosted site.") + static let username = NSLocalizedString("Username", + comment: "Accessibility label for the username text field in the self-hosted login page.") + static let password = NSLocalizedString("Password", + comment: "Accessibility label for the password text field in the self-hosted login page.") + static let otp = NSLocalizedString("Authentication code", + comment: "Accessibility label for the 2FA text field.") + static let email = NSLocalizedString("Email address", + comment: "Accessibility label for the email address text field.") + static let siteAddressID = "Site address" + static let usernameID = "Username" + static let passwordID = "Password" + static let otpID = "Authentication code" + static let emailID = "Email address" + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.xib new file mode 100644 index 000000000000..c0ada44fb922 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextFieldTableViewCell.xib @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.swift new file mode 100644 index 000000000000..59ec4ba96bd7 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.swift @@ -0,0 +1,43 @@ +import UIKit + +/// TextLabelTableViewCell: a text label in a UITableViewCell. +/// +public final class TextLabelTableViewCell: UITableViewCell { + + /// Private properties + /// + @IBOutlet private weak var label: UILabel! + + /// Public properties + /// + public static let reuseIdentifier = "TextLabelTableViewCell" + + public func configureLabel(text: String?, style: TextLabelStyle = .body) { + label.text = text + + switch style { + case .body: + label.textColor = WordPressAuthenticator.shared.unifiedStyle?.textColor ?? WordPressAuthenticator.shared.style.instructionColor + label.font = UIFont.preferredFont(forTextStyle: .body) + case .error: + label.textColor = WordPressAuthenticator.shared.unifiedStyle?.errorColor ?? UIColor.red + label.font = UIFont.preferredFont(forTextStyle: .body) + } + } + + /// Override methods + /// + public override func prepareForReuse() { + super.prepareForReuse() + label.text = nil + } +} + +public extension TextLabelTableViewCell { + /// The label style to display + /// + enum TextLabelStyle { + case body + case error + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.xib new file mode 100644 index 000000000000..734e76ccc2ff --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLabelTableViewCell.xib @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.swift new file mode 100644 index 000000000000..747a0069af08 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.swift @@ -0,0 +1,76 @@ +import UIKit + +/// TextLinkButtonTableViewCell: a plain button made to look like a text link. +/// +class TextLinkButtonTableViewCell: UITableViewCell { + + /// Private properties + /// + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var button: UIButton! + @IBOutlet private weak var borderView: UIView! + @IBOutlet private weak var borderWidth: NSLayoutConstraint! + @IBAction private func textLinkButtonTapped(_ sender: UIButton) { + actionHandler?() + } + + /// Public properties + /// + public static let reuseIdentifier = "TextLinkButtonTableViewCell" + + public var actionHandler: (() -> Void)? + + override func awakeFromNib() { + super.awakeFromNib() + + button.titleLabel?.adjustsFontForContentSizeCategory = true + styleBorder() + } + + public func configureButton(text: String?, + icon: UIImage? = nil, + accessibilityTrait: UIAccessibilityTraits = .button, + showBorder: Bool = false, + accessibilityIdentifier: String? = nil) { + button.setTitle(text, for: .normal) + + let buttonTitleColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonColor ?? WordPressAuthenticator.shared.style.textButtonColor + let buttonHighlightColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonHighlightColor ?? WordPressAuthenticator.shared.style.textButtonHighlightColor + button.setTitleColor(buttonTitleColor, for: .normal) + button.setTitleColor(buttonHighlightColor, for: .highlighted) + button.accessibilityTraits = accessibilityTrait + button.accessibilityIdentifier = accessibilityIdentifier + + borderView.isHidden = !showBorder + + iconView.image = icon + iconView.isHidden = icon == nil + iconView.tintColor = buttonTitleColor + } + + /// Toggle button enabled / disabled + /// + public func enableButton(_ isEnabled: Bool) { + button.isEnabled = isEnabled + } + +} + +// MARK: - Private methods +private extension TextLinkButtonTableViewCell { + + /// Style the bottom cell border, called borderView. + /// + func styleBorder() { + let borderColor = WordPressAuthenticator.shared.unifiedStyle?.borderColor ?? WordPressAuthenticator.shared.style.primaryNormalBorderColor + borderView.backgroundColor = borderColor + borderWidth.constant = WPStyleGuide.hairlineBorderWidth + } +} + +// MARK: - Constants +extension TextLinkButtonTableViewCell { + struct Constants { + static let passkeysID = "Passkeys" + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.xib new file mode 100644 index 000000000000..37d749d71ce0 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextLinkButtonTableViewCell.xib @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.swift new file mode 100644 index 000000000000..9028f54fa91d --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.swift @@ -0,0 +1,43 @@ +import UIKit + +/// TextWithLinkTableViewCell: a button with the title regular text and an underlined link. +/// +class TextWithLinkTableViewCell: UITableViewCell { + + /// Public properties + /// + static let reuseIdentifier = "TextWithLinkTableViewCell" + var actionHandler: (() -> Void)? + + /// Private properties + /// + @IBOutlet private weak var button: UIButton! + @IBAction private func buttonTapped(_ sender: UIButton) { + actionHandler?() + } + + override func awakeFromNib() { + super.awakeFromNib() + button.titleLabel?.adjustsFontForContentSizeCategory = true + } + + /// Creates an attributed string from the provided marked text and assigns it to the button title. + /// + /// - Parameters: + /// - markedText: string with the text to be formatted as a link marked with "_". + /// Example: "this _is_ a link" will format "is" as an underlined link. + /// - accessibilityTrait: accessibilityTrait of button (optional) + /// + func configureButton(markedText text: String, accessibilityTrait: UIAccessibilityTraits = .link) { + let textColor = WordPressAuthenticator.shared.unifiedStyle?.textSubtleColor ?? WordPressAuthenticator.shared.style.subheadlineColor + let linkColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonColor ?? WordPressAuthenticator.shared.style.textButtonColor + let linkHighlightColor = WordPressAuthenticator.shared.unifiedStyle?.textButtonHighlightColor ?? WordPressAuthenticator.shared.style.textButtonHighlightColor + + let attributedString = text.underlined(color: textColor, underlineColor: linkColor) + let highlightAttributedString = text.underlined(color: textColor, underlineColor: linkHighlightColor) + + button.setAttributedTitle(attributedString, for: .normal) + button.setAttributedTitle(highlightAttributedString, for: .highlighted) + button.accessibilityTraits = accessibilityTrait + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.xib b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.xib new file mode 100644 index 000000000000..db080cb388b9 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Reusable Views/TextWithLinkTableViewCell.xib @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/SignupMagicLinkViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/SignupMagicLinkViewController.swift new file mode 100644 index 000000000000..0a9078db047f --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/SignupMagicLinkViewController.swift @@ -0,0 +1,195 @@ +import UIKit + +/// SignupMagicLinkViewController: step two in the signup flow. +/// This VC prompts the user to open their email app to look for the magic link we sent. +/// +final class SignupMagicLinkViewController: LoginViewController { + + // MARK: Properties + + @IBOutlet private weak var tableView: UITableView! + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + override var sourceTag: WordPressSupportSourceTag { + get { + return .wpComSignupMagicLink + } + } + + // MARK: - Actions + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .openEmailClient) + tracker.track(step: .emailOpened) + + let linkMailPresenter = LinkMailPresenter(emailAddress: loginFields.username) + let appSelector = AppSelector(sourceView: sender) + linkMailPresenter.presentEmailClients(on: self, appSelector: appSelector) + } + + // MARK: - View lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + validationCheck() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.signUpTitle + styleNavigationBar(forUnified: true) + + // Store default margin, and size table for the view. + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if isMovingToParent { + tracker.track(step: .magicLinkRequested) + } else { + tracker.set(step: .magicLinkRequested) + } + } + + /// Validation check while we are bypassing screens. + /// + func validationCheck() { + let email = loginFields.username + if !email.isValidEmail() { + WPAuthenticatorLogError("The value of loginFields.username was not a valid email address.") + } + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Override the title on 'submit' button + /// + override func localizePrimaryButton() { + submitButton?.setTitle(WordPressAuthenticator.shared.displayStrings.openMailButtonTitle, for: .normal) + } +} + +// MARK: - UITableViewDataSource +extension SignupMagicLinkViewController: UITableViewDataSource { + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - Private Methods +private extension SignupMagicLinkViewController { + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.persona, .instructions, .checkSpam, .oops] + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell where row == .persona: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .checkSpam: + configureCheckSpamLabel(cell) + case let cell as TextLabelTableViewCell where row == .oops: + configureoopsLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username) + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.openMailSignupInstructions, style: .body) + } + + /// Configure the "Check spam" cell. + /// + func configureCheckSpamLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.checkSpamInstructions, style: .body) + } + + /// Configure the "Check spam" cell. + /// + func configureoopsLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.oopsInstructions, style: .body) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case persona + case instructions + case checkSpam + case oops + + var reuseIdentifier: String { + switch self { + case .persona: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions, .checkSpam, .oops: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignup.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignup.storyboard new file mode 100644 index 000000000000..2b0ec0487483 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignup.storyboard @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignupViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignupViewController.swift new file mode 100644 index 000000000000..2d953b2e0155 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Sign up/UnifiedSignupViewController.swift @@ -0,0 +1,251 @@ +import UIKit + +/// UnifiedSignupViewController: sign up to .com with an email address. +/// +class UnifiedSignupViewController: LoginViewController { + + /// Private properties. + /// + @IBOutlet private weak var tableView: UITableView! + + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + // MARK: - Actions + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .requestMagicLink) + requestAuthenticationLink() + } + + // MARK: - View lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.signUpTitle + styleNavigationBar(forUnified: true) + + // Store default margin, and size table for the view. + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + tracker.set(flow: .signup) + + if isMovingToParent { + tracker.track(step: .start) + } else { + tracker.set(step: .start) + } + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Override the title on 'submit' button + /// + override func localizePrimaryButton() { + submitButton?.setTitle(WordPressAuthenticator.shared.displayStrings.magicLinkButtonTitle, for: .normal) + } + + /// Reload the tableview and show errors, if any. + /// + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + if errorMessage != message { + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } +} + +// MARK: - UITableViewDataSource +extension UnifiedSignupViewController: UITableViewDataSource { + + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - UITableViewDelegate conformance +extension UnifiedSignupViewController: UITableViewDelegate { } + +// MARK: - Private methods +private extension UnifiedSignupViewController { + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.gravatarEmail, .instructions] + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username) + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.magicLinkSignupInstructions, style: .body) + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case gravatarEmail + case instructions + case errorMessage + + var reuseIdentifier: String { + switch self { + case .gravatarEmail: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions: + return TextLabelTableViewCell.reuseIdentifier + case .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + } + } + } + + enum ErrorMessage: String { + case availabilityCheckFail = "availability_check_fail" + case magicLinkRequestFail = "magic_link_request_fail" + + func description() -> String { + switch self { + case .availabilityCheckFail: + return NSLocalizedString("Unable to verify the email address. Please try again later.", comment: "Error message displayed when an error occurred checking for email availability.") + case .magicLinkRequestFail: + return NSLocalizedString("We were unable to send you an email at this time. Please try again later.", comment: "Error message displayed when an error occurred sending the magic link email.") + } + } + } + +} + +// MARK: - Instance Methods +/// Implementation methods imported from SignupEmailViewController. +/// +extension UnifiedSignupViewController { + // MARK: - Send email + + /// Makes the call to request a magic signup link be emailed to the user. + /// + func requestAuthenticationLink() { + loginFields.meta.emailMagicLinkSource = .signup + + configureSubmitButton(animating: true) + + let service = WordPressComAccountService() + service.requestSignupLink(for: loginFields.username, + success: { [weak self] in + self?.didRequestSignupLink() + self?.configureSubmitButton(animating: false) + + }, failure: { [weak self] (error: Error) in + WPAuthenticatorLogError("Request for signup link email failed.") + + guard let self = self else { + return + } + + self.tracker.track(failure: error.localizedDescription) + self.displayError(message: ErrorMessage.magicLinkRequestFail.description()) + self.configureSubmitButton(animating: false) + }) + } + + func didRequestSignupLink() { + guard let vc = SignupMagicLinkViewController.instantiate(from: .unifiedSignup) else { + WPAuthenticatorLogError("Failed to navigate from UnifiedSignupViewController to SignupMagicLinkViewController") + return + } + + vc.loginFields = loginFields + vc.loginFields.restrictToWPCom = true + + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddress.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddress.storyboard new file mode 100644 index 000000000000..daf2db63f942 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddress.storyboard @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewController.swift new file mode 100644 index 000000000000..23b33b28b559 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewController.swift @@ -0,0 +1,692 @@ +import UIKit +import WordPressUI +import WordPressKit + +/// SiteAddressViewController: log in by Site Address. +/// +final class SiteAddressViewController: LoginViewController { + + /// Private properties. + /// + @IBOutlet private weak var tableView: UITableView! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + + // Required for `NUXKeyboardResponder` but unused here. + var verticalCenterConstraint: NSLayoutConstraint? + + private var rows = [Row]() + private weak var siteURLField: UITextField? + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + /// A state variable that is `true` if network calls are currently happening and so the + /// view should be showing a loading indicator. + /// + /// This should only be modified within `configureViewLoading(_ loading:)`. + /// + /// This state is mainly used in `configureSubmitButton()` to determine whether the button + /// should show an activity indicator. + private var viewIsLoading: Bool = false + + /// Whether the protocol method `troubleshootSite` should be triggered after site info is fetched. + /// + private let isSiteDiscovery: Bool + private let configuration = WordPressAuthenticator.shared.configuration + private lazy var viewModel: SiteAddressViewModel = { + return SiteAddressViewModel( + isSiteDiscovery: isSiteDiscovery, + xmlrpcFacade: WordPressXMLRPCAPIFacade(), + authenticationDelegate: authenticationDelegate, + blogService: WordPressComBlogService(), + loginFields: loginFields + ) + }() + + init?(isSiteDiscovery: Bool, coder: NSCoder) { + self.isSiteDiscovery = isSiteDiscovery + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + self.isSiteDiscovery = false + super.init(coder: coder) + } + + // MARK: - Actions + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .submit) + + validateForm() + } + + // MARK: - View lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + removeGoogleWaitingView() + configureNavBar() + setupTable() + localizePrimaryButton() + registerTableViewCells() + loadRows() + configureSubmitButton() + configureForAccessibility() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + siteURLField?.text = loginFields.siteAddress + configureSubmitButton() + + // Nav bar could be hidden from the host app, so reshow it. + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if isSiteDiscovery { + tracker.set(flow: .siteDiscovery) + } else { + tracker.set(flow: .loginWithSiteAddress) + } + + if isMovingToParent { + tracker.track(step: .start) + } else { + tracker.set(step: .start) + } + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + configureViewForEditingIfNeeded() + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Configures the appearance and state of the submit button. + /// + /// Use this instead of the overridden `configureSubmitButton(animating:)` since this uses the + /// _current_ `viewIsLoading` state. + private func configureSubmitButton() { + configureSubmitButton(animating: viewIsLoading) + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + // This matches the string in WPiOS UI tests. + submitButton?.accessibilityIdentifier = "Site Address Next Button" + + submitButton?.showActivityIndicator(animating) + + submitButton?.isEnabled = ( + !animating && canSubmit() + ) + } + + /// Sets up accessibility elements in the order which they should be read aloud + /// and quiets repetitive elements. + /// + private func configureForAccessibility() { + view.accessibilityElements = [ + siteURLField as Any, + tableView as Any, + submitButton as Any + ] + + UIAccessibility.post(notification: .screenChanged, argument: siteURLField) + + if UIAccessibility.isVoiceOverRunning { + // Remove the placeholder if VoiceOver is running, because it speaks the label + // and the placeholder together. Since the placeholder matches the label, it's + // like VoiceOver is reading the same thing twice. + siteURLField?.placeholder = nil + } + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + viewIsLoading = loading + + siteURLField?.isEnabled = !loading + + configureSubmitButton() + navigationItem.hidesBackButton = loading + } + + /// Configure the view for an editing state. Should only be called from viewWillAppear + /// as this method skips animating any change in height. + /// + @objc func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + siteURLField?.becomeFirstResponder() + } + } + + override func displayRemoteError(_ error: Error) { + guard authenticationDelegate.shouldHandleError(error) else { + super.displayRemoteError(error) + return + } + + authenticationDelegate.handleError(error) { customUI in + self.navigationController?.pushViewController(customUI, animated: true) + } + } + + /// Reload the tableview and show errors, if any. + /// + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + if errorMessage != message { + if !message.isEmpty { + tracker.track(failure: message) + } + + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } +} + +// MARK: - UITableViewDataSource +extension SiteAddressViewController: UITableViewDataSource { + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - Keyboard Notifications +extension SiteAddressViewController: NUXKeyboardResponder { + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +// MARK: - TextField Delegate conformance +extension SiteAddressViewController: UITextFieldDelegate { + + /// Handle the keyboard `return` button action. + /// + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if canSubmit() { + validateForm() + return true + } + + return false + } +} + +// MARK: - Private methods +private extension SiteAddressViewController { + + // MARK: - Configuration + + func configureNavBar() { + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + } + + func setupTable() { + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + } + + // MARK: - Table Management + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib(), + TextFieldTableViewCell.reuseIdentifier: TextFieldTableViewCell.loadNib(), + TextLinkButtonTableViewCell.reuseIdentifier: TextLinkButtonTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.instructions, .siteAddress] + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + + if WordPressAuthenticator.shared.configuration.displayHintButtons { + rows.append(.findSiteAddress) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextFieldTableViewCell: + configureTextField(cell) + case let cell as TextLinkButtonTableViewCell: + configureTextLinkButton(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.siteLoginInstructions, style: .body) + } + + /// Configure the textfield cell. + /// + func configureTextField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .url, + placeholder: WordPressAuthenticator.shared.displayStrings.siteAddressPlaceholder) + + // Save a reference to the first textField so it can becomeFirstResponder. + siteURLField = cell.textField + cell.textField.delegate = self + cell.textField.text = loginFields.siteAddress + cell.onChangeSelectionHandler = { [weak self] textfield in + self?.loginFields.siteAddress = textfield.nonNilTrimmedText() + self?.configureSubmitButton() + } + + SigninEditingState.signinEditingStateActive = true + } + + /// Configure the "Find your site address" cell. + /// + func configureTextLinkButton(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.findSiteButtonTitle) + cell.actionHandler = { [weak self] in + guard let self = self else { + return + } + + self.tracker.track(click: .showHelp) + + let alert = FancyAlertViewController.siteAddressHelpController( + loginFields: self.loginFields, + sourceTag: self.sourceTag, + moreHelpTapped: { + self.tracker.track(click: .helpFindingSiteAddress) + }, + onDismiss: { + self.tracker.track(click: .dismiss) + + // Since we're showing an alert on top of this VC, `viewDidAppear` will not be called + // once the alert is dismissed (which is where the step would be reset automagically), + // so we need to manually reset the step here. + self.tracker.set(step: .start) + }) + alert.modalPresentationStyle = .custom + alert.transitioningDelegate = self + self.present(alert, animated: true, completion: { [weak self] in + self?.tracker.track(step: .help) + }) + } + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Push a custom view controller, provided by a host app, to the navigation stack + func pushCustomUI(_ customUI: UIViewController) { + /// Assign the help button of the newly injected UI to the same help button we are currently displaying + /// We are making a somewhat big assumption here: the chrome of the new UI we insert would look like the UI + /// WPAuthenticator is already displaying. Which is risky, but also kind of makes sense, considering + /// we are also pushing that injected UI to the current navigation controller. + if WordPressAuthenticator.shared.delegate?.supportActionEnabled == true { + customUI.navigationItem.rightBarButtonItems = self.navigationItem.rightBarButtonItems + } + + self.navigationController?.pushViewController(customUI, animated: true) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case instructions + case siteAddress + case findSiteAddress + case errorMessage + + var reuseIdentifier: String { + switch self { + case .instructions: + return TextLabelTableViewCell.reuseIdentifier + case .siteAddress: + return TextFieldTableViewCell.reuseIdentifier + case .findSiteAddress: + return TextLinkButtonTableViewCell.reuseIdentifier + case .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} + +// MARK: - Instance Methods + +private extension SiteAddressViewController { + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + func validateForm() { + view.endEditing(true) + displayError(message: "") + + // We need to to this here because before this point we need the URL to be pre-validated + // exactly as the user inputs it, and after this point we need the URL to be the base site URL. + // This isn't really great, but it's the only sane solution I could come up with given the current + // architecture of this pod. + loginFields.siteAddress = WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + + configureViewLoading(true) + + guard let url = URL(string: loginFields.siteAddress) else { + configureViewLoading(false) + return displayError(message: Localization.invalidURL, moveVoiceOverFocus: true) + } + + // Checks that the site exists + checkSiteExistence(url: url) { [weak self] in + guard let self = self else { return } + // skips XMLRPC check for site discovery or site address login if needed + if (self.isSiteDiscovery && self.configuration.skipXMLRPCCheckForSiteDiscovery) || + self.configuration.skipXMLRPCCheckForSiteAddressLogin { + self.fetchSiteInfo() + return + } + // Proceeds to check for the site's WordPress + self.guessXMLRPCURL(for: url.absoluteString) + } + } + + func checkSiteExistence(url: URL, onCompletion: @escaping () -> Void) { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 10.0 // waits for 10 seconds + let task = URLSession.shared.dataTask(with: request) { [weak self] _, _, error in + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + if let error = error, (error as NSError).code != NSURLErrorAppTransportSecurityRequiresSecureConnection { + self.configureViewLoading(false) + + if self.authenticationDelegate.shouldHandleError(error) { + self.authenticationDelegate.handleError(error) { customUI in + self.pushCustomUI(customUI) + } + return + } + + var message: String? + + // Use `URLError`'s error message (which usually contains more accurate description), if the + // error is SSL error. + if let urlError = error as? URLError, urlError.failureURLPeerTrust != nil { + message = urlError.localizedDescription + } + + return self.displayError(message: message ?? Localization.nonExistentSiteError, moveVoiceOverFocus: true) + } + + onCompletion() + } + } + task.resume() + } + + func guessXMLRPCURL(for siteAddress: String) { + viewModel.guessXMLRPCURL( + for: siteAddress, + loading: { [weak self] isLoading in + self?.configureViewLoading(isLoading) + }, + completion: { [weak self] result -> Void in + guard let self else { return } + switch result { + case .success: + // Let's try to grab site info in preparation for the next screen. + self.fetchSiteInfo() + case .error(let error, let errorMessage): + if let message = errorMessage { + self.displayError(message: message, moveVoiceOverFocus: true) + } else { + self.displayError(error, sourceTag: self.sourceTag) + } + case .troubleshootSite: + WordPressAuthenticator.shared.delegate?.troubleshootSite(nil, in: self.navigationController) + case .customUI(let viewController): + self.pushCustomUI(viewController) + } + }) + } + + func fetchSiteInfo() { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + let service = WordPressComBlogService() + + let successBlock: (WordPressComSiteInfo) -> Void = { [weak self] siteInfo in + guard let self = self else { + return + } + self.configureViewLoading(false) + if siteInfo.isWPCom && WordPressAuthenticator.shared.delegate?.allowWPComLogin == false { + // Hey, you have to log out of your existing WP.com account before logging into another one. + self.promptUserToLogoutBeforeConnectingWPComSite() + return + } + self.presentNextControllerIfPossible(siteInfo: siteInfo) + } + + service.fetchUnauthenticatedSiteInfoForAddress(for: baseSiteUrl, success: successBlock, failure: { [weak self] error in + self?.configureViewLoading(false) + guard let self = self else { + return + } + + if self.authenticationDelegate.shouldHandleError(error) { + self.authenticationDelegate.handleError(error) { [weak self] customUI in + self?.navigationController?.pushViewController(customUI, animated: true) + } + } else { + self.displayError(message: Localization.invalidURL) + } + }) + } + + func presentNextControllerIfPossible(siteInfo: WordPressComSiteInfo?) { + + // Ensure that we're using the verified URL before passing the `loginFields` to the next + // view controller. + // + // In some scenarios, the text field change callback in `configureTextField()` gets executed + // right after we validated and modified `loginFields.siteAddress` in `validateForm()`. And + // this causes the value of `loginFields.siteAddress` to be reset to what the user entered. + // + // Using the user-entered `loginFields.siteAddress` causes problems when we try to log + // the user in especially if they just use a domain. For example, validating their + // self-hosted site credentials fails because the + // `WordPressOrgXMLRPCValidator.guessXMLRPCURLForSite` expects a complete site URL. + // + // This routine fixes that problem. We'll use what we already validated from + // `fetchSiteInfo()`. + // + if let verifiedSiteAddress = siteInfo?.url { + loginFields.siteAddress = verifiedSiteAddress + } + + guard isSiteDiscovery == false else { + WordPressAuthenticator.shared.delegate?.troubleshootSite(siteInfo, in: navigationController) + return + } + + guard siteInfo?.isWPCom == false else { + showGetStarted() + return + } + + WordPressAuthenticator.shared.delegate?.shouldPresentUsernamePasswordController(for: siteInfo, onCompletion: { (result) in + switch result { + case let .error(error): + self.displayError(message: error.localizedDescription) + case let .presentPasswordController(isSelfHosted): + if isSelfHosted { + self.showSelfHostedUsernamePassword() + return + } + + self.showWPUsernamePassword() + case .presentEmailController: + self.showGetStarted() + case let .injectViewController(customUI): + self.pushCustomUI(customUI) + } + }) + } + + func originalErrorOrError(error: NSError) -> NSError { + guard let err = error.userInfo[XMLRPCOriginalErrorKey] as? NSError else { + return error + } + + return err + } + + /// Here we will continue with the self-hosted flow. + /// + func showSelfHostedUsernamePassword() { + configureViewLoading(false) + guard let vc = SiteCredentialsViewController.instantiate(from: .siteAddress) else { + WPAuthenticatorLogError("Failed to navigate from SiteAddressViewController to SiteCredentialsViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Break away from the self-hosted flow. + /// Display a username / password login screen for WP.com sites. + /// + func showWPUsernamePassword() { + configureViewLoading(false) + + guard let vc = LoginUsernamePasswordViewController.instantiate(from: .login) else { + WPAuthenticatorLogError("Failed to navigate from SiteAddressViewController to LoginUsernamePasswordViewController") + return + } + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// If the site is WordPressDotCom, redirect to WP login. + /// + func showGetStarted() { + guard let vc = GetStartedViewController.instantiate(from: .getStarted) else { + WPAuthenticatorLogError("Failed to navigate from SiteAddressViewController to GetStartedViewController") + return + } + vc.source = .wpComSiteAddress + + vc.loginFields = loginFields + vc.dismissBlock = dismissBlock + vc.errorToPresent = errorToPresent + + navigationController?.pushViewController(vc, animated: true) + } + + /// Whether the form can be submitted. + /// + func canSubmit() -> Bool { + return loginFields.validateSiteForSignin() + } + + @objc private func promptUserToLogoutBeforeConnectingWPComSite() { + let acceptActionTitle = NSLocalizedString("OK", comment: "Alert dismissal title") + let message = NSLocalizedString("Please log out before connecting to a different wordpress.com site", comment: "Message for alert to prompt user to logout before connecting to a different wordpress.com site.") + let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alertController.addDefaultActionWithTitle(acceptActionTitle) + present(alertController, animated: true) + } +} + +private extension SiteAddressViewController { + enum Localization { + static let invalidURL = NSLocalizedString( + "Invalid URL. Please double-check and try again.", + comment: "Error message shown when the input URL is invalid.") + static let nonExistentSiteError = NSLocalizedString( + "Cannot access the site at this address. Please double-check and try again.", + comment: "Error message shown when the input URL does not point to an existing site.") + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewModel.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewModel.swift new file mode 100644 index 000000000000..59c2f46be10b --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteAddressViewModel.swift @@ -0,0 +1,136 @@ +import Foundation +import WordPressKit + +struct SiteAddressViewModel { + private let isSiteDiscovery: Bool + private let xmlrpcFacade: WordPressXMLRPCAPIFacade + private unowned let authenticationDelegate: WordPressAuthenticatorDelegate + private let blogService: WordPressComBlogService + private var loginFields: LoginFields + + private let tracker = AuthenticatorAnalyticsTracker.shared + + init(isSiteDiscovery: Bool, + xmlrpcFacade: WordPressXMLRPCAPIFacade, + authenticationDelegate: WordPressAuthenticatorDelegate, + blogService: WordPressComBlogService, + loginFields: LoginFields + ) { + self.isSiteDiscovery = isSiteDiscovery + self.xmlrpcFacade = xmlrpcFacade + self.authenticationDelegate = authenticationDelegate + self.blogService = blogService + self.loginFields = loginFields + } + + enum GuessXMLRPCURLResult: Equatable { + case success + case error(NSError, String?) + case troubleshootSite + case customUI(UIViewController) + } + + func guessXMLRPCURL( + for siteAddress: String, + loading: @escaping ((Bool) -> ()), + completion: @escaping (GuessXMLRPCURLResult) -> () + ) { + xmlrpcFacade.guessXMLRPCURL(forSite: siteAddress, success: { url in + // Success! We now know that we have a valid XML-RPC endpoint. + // At this point, we do NOT know if this is a WP.com site or a self-hosted site. + if let url = url { + self.loginFields.meta.xmlrpcURL = url as NSURL + } + + completion(.success) + + }, failure: { error in + guard let error = error else { + return + } + // Intentionally log the attempted address on failures. + // It's not guaranteed to be included in the error object depending on the error. + WPAuthenticatorLogInfo("Error attempting to connect to site address: \(self.loginFields.siteAddress)") + WPAuthenticatorLogError(error.localizedDescription) + + self.tracker.track(failure: .loginFailedToGuessXMLRPC) + + loading(false) + + guard self.isSiteDiscovery == false else { + completion(.troubleshootSite) + return + } + + let err = self.originalErrorOrError(error: error as NSError) + self.handleGuessXMLRPCURLError(error: err, loading: loading, completion: completion) + }) + } + + private func handleGuessXMLRPCURLError( + error: NSError, + loading: @escaping ((Bool) -> ()), + completion: @escaping (GuessXMLRPCURLResult) -> () + ) { + let completion: (NSError, String?) -> Void = { error, errorMessage in + if self.authenticationDelegate.shouldHandleError(error) { + self.authenticationDelegate.handleError(error) { customUI in + completion(.customUI(customUI)) + } + if let message = errorMessage { + self.tracker.track(failure: message) + } + return + } + + completion(.error(error, errorMessage)) + } + + /// Confirm the site is not a WordPress site before describing it as an invalid WP site + if let xmlrpcValidatorError = error as? WordPressOrgXMLRPCValidatorError, xmlrpcValidatorError == .invalid { + loading(true) + isWPSite { isWP in + loading(false) + if isWP { + let error = WordPressOrgXMLRPCValidatorError.xmlrpc_missing + completion(error as NSError, error.localizedDescription) + } else { + completion(error, Strings.notWPSiteErrorMessage) + } + } + } else if (error.domain == NSURLErrorDomain && error.code == NSURLErrorCannotFindHost) || + (error.domain == NSURLErrorDomain && error.code == NSURLErrorNetworkConnectionLost) { + completion(error, Strings.notWPSiteErrorMessage) + } else { + completion(error, (error as? WordPressOrgXMLRPCValidatorError)?.localizedDescription) + } + } + + private func originalErrorOrError(error: NSError) -> NSError { + guard let err = error.userInfo[XMLRPCOriginalErrorKey] as? NSError else { + return error + } + + return err + } +} + +extension SiteAddressViewModel { + private func isWPSite(_ completion: @escaping (Bool) -> ()) { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: loginFields.siteAddress) + blogService.fetchUnauthenticatedSiteInfoForAddress( + for: baseSiteUrl, + success: { siteInfo in + completion(siteInfo.isWP) + }, + failure: { _ in + completion(false) + }) + } +} + +private extension SiteAddressViewModel { + struct Strings { + static let notWPSiteErrorMessage = NSLocalizedString("The site at this address is not a WordPress site. For us to connect to it, the site must use WordPress.", comment: "Error message shown when a URL does not point to an existing site.") + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteCredentialsViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteCredentialsViewController.swift new file mode 100644 index 000000000000..012250bef853 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/Site Address/SiteCredentialsViewController.swift @@ -0,0 +1,589 @@ +import UIKit + +/// Part two of the self-hosted sign in flow: username + password. Used by WPiOS and NiOS. +/// A valid site address should be acquired before presenting this view controller. +/// +final class SiteCredentialsViewController: LoginViewController { + + /// Private properties. + /// + @IBOutlet private weak var tableView: UITableView! + @IBOutlet var bottomContentConstraint: NSLayoutConstraint? + + private weak var usernameField: UITextField? + private weak var passwordField: UITextField? + private var rows = [Row]() + private var errorMessage: String? + private var shouldChangeVoiceOverFocus: Bool = false + + private let isDismissible: Bool + private let completionHandler: ((WordPressOrgCredentials) -> Void)? + private let configuration = WordPressAuthenticator.shared.configuration + + init?(coder: NSCoder, isDismissible: Bool, onCompletion: @escaping (WordPressOrgCredentials) -> Void) { + self.isDismissible = isDismissible + self.completionHandler = onCompletion + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + self.isDismissible = false + self.completionHandler = nil + super.init(coder: coder) + } + + // Required for `NUXKeyboardResponder` but unused here. + var verticalCenterConstraint: NSLayoutConstraint? + + override var sourceTag: WordPressSupportSourceTag { + get { + return .loginUsernamePassword + } + } + + override var loginFields: LoginFields { + didSet { + // Clear the password (if any) from LoginFields + loginFields.password = "" + } + } + + // MARK: - Actions + @IBAction func handleContinueButtonTapped(_ sender: NUXButton) { + tracker.track(click: .submit) + + validateForm() + } + + // MARK: - View lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + loginFields.meta.userIsDotCom = false + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + if isDismissible { + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissView)) + } + styleNavigationBar(forUnified: true) + + // Store default margin, and size table for the view. + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + localizePrimaryButton() + registerTableViewCells() + loadRows() + configureForAccessibility() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if isMovingToParent { + tracker.track(step: .usernamePassword) + } else { + tracker.set(step: .usernamePassword) + } + + configureSubmitButton(animating: false) + configureViewLoading(false) + + registerForKeyboardEvents(keyboardWillShowAction: #selector(handleKeyboardWillShow(_:)), + keyboardWillHideAction: #selector(handleKeyboardWillHide(_:))) + configureViewForEditingIfNeeded() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + unregisterForKeyboardEvents() + } + + // MARK: - Overrides + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + super.styleBackground() + return + } + + view.backgroundColor = unifiedBackgroundColor + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Configures the appearance and state of the submit button. + /// + override func configureSubmitButton(animating: Bool) { + submitButton?.showActivityIndicator(animating) + + submitButton?.isEnabled = ( + !animating && + !loginFields.username.isEmpty && + !loginFields.password.isEmpty + ) + } + + /// Sets up accessibility elements in the order which they should be read aloud + /// and chooses which element to focus on at the beginning. + /// + private func configureForAccessibility() { + view.accessibilityElements = [ + usernameField as Any, + tableView as Any, + submitButton as Any + ] + + UIAccessibility.post(notification: .screenChanged, argument: usernameField) + } + + /// Sets the view's state to loading or not loading. + /// + /// - Parameter loading: True if the form should be configured to a "loading" state. + /// + override func configureViewLoading(_ loading: Bool) { + usernameField?.isEnabled = !loading + passwordField?.isEnabled = !loading + + configureSubmitButton(animating: loading) + navigationItem.hidesBackButton = loading + } + + /// Set error messages and reload the table to display them. + /// + override func displayError(message: String, moveVoiceOverFocus: Bool = false) { + if errorMessage != message { + if !message.isEmpty { + tracker.track(failure: message) + } + + errorMessage = message + shouldChangeVoiceOverFocus = moveVoiceOverFocus + loadRows() + tableView.reloadData() + } + } + + /// No-op. Required by LoginFacade. + func displayLoginMessage(_ message: String) {} +} + +// MARK: - UITableViewDataSource +extension SiteCredentialsViewController: UITableViewDataSource { + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - UITableViewDelegate conformance +extension SiteCredentialsViewController: UITableViewDelegate { + /// After a textfield cell is done displaying, remove the textfield reference. + /// + func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + guard let row = rows[safe: indexPath.row] else { + return + } + + if row == .username { + usernameField = nil + } else if row == .password { + passwordField = nil + } + } +} + +// MARK: - Keyboard Notifications +extension SiteCredentialsViewController: NUXKeyboardResponder { + @objc func handleKeyboardWillShow(_ notification: Foundation.Notification) { + keyboardWillShow(notification) + } + + @objc func handleKeyboardWillHide(_ notification: Foundation.Notification) { + keyboardWillHide(notification) + } +} + +// MARK: - TextField Delegate conformance +extension SiteCredentialsViewController: UITextFieldDelegate { + + /// Handle the keyboard `return` button action. + /// + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == usernameField { + if UIAccessibility.isVoiceOverRunning { + passwordField?.placeholder = nil + } + passwordField?.becomeFirstResponder() + } else if textField == passwordField { + validateForm() + } + return true + } +} + +// MARK: - Private Methods +private extension SiteCredentialsViewController { + + @objc func dismissView() { + dismissBlock?(true) + } + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib(), + TextFieldTableViewCell.reuseIdentifier: TextFieldTableViewCell.loadNib(), + TextLinkButtonTableViewCell.reuseIdentifier: TextLinkButtonTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Describes how the tableView rows should be rendered. + /// + func loadRows() { + rows = [.instructions, .username, .password] + + if let errorText = errorMessage, !errorText.isEmpty { + rows.append(.errorMessage) + } + + if configuration.displayHintButtons { + rows.append(.forgotPassword) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextFieldTableViewCell where row == .username: + configureUsernameTextField(cell) + case let cell as TextFieldTableViewCell where row == .password: + configurePasswordTextField(cell) + case let cell as TextLinkButtonTableViewCell: + configureForgotPassword(cell) + case let cell as TextLabelTableViewCell where row == .errorMessage: + configureErrorLabel(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the instruction cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + let displayURL = sanitizedSiteAddress(siteAddress: loginFields.siteAddress) + let text = String.localizedStringWithFormat(WordPressAuthenticator.shared.displayStrings.siteCredentialInstructions, displayURL) + cell.configureLabel(text: text, style: .body) + } + + /// Configure the username textfield cell. + /// + func configureUsernameTextField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .username, + placeholder: WordPressAuthenticator.shared.displayStrings.usernamePlaceholder, + text: loginFields.username) + + // Save a reference to the textField so it can becomeFirstResponder. + usernameField = cell.textField + cell.textField.delegate = self + + cell.onChangeSelectionHandler = { [weak self] textfield in + self?.loginFields.username = textfield.nonNilTrimmedText() + self?.configureSubmitButton(animating: false) + } + + SigninEditingState.signinEditingStateActive = true + if UIAccessibility.isVoiceOverRunning { + // Quiet repetitive elements in VoiceOver. + usernameField?.placeholder = nil + } + } + + /// Configure the password textfield cell. + /// + func configurePasswordTextField(_ cell: TextFieldTableViewCell) { + cell.configure(withStyle: .password, + placeholder: WordPressAuthenticator.shared.displayStrings.passwordPlaceholder, + text: loginFields.password) + passwordField = cell.textField + cell.textField.delegate = self + cell.onChangeSelectionHandler = { [weak self] textfield in + self?.loginFields.password = textfield.nonNilTrimmedText() + self?.configureSubmitButton(animating: false) + } + + if UIAccessibility.isVoiceOverRunning { + // Quiet repetitive elements in VoiceOver. + passwordField?.placeholder = nil + } + } + + /// Configure the forgot password cell. + /// + func configureForgotPassword(_ cell: TextLinkButtonTableViewCell) { + cell.configureButton(text: WordPressAuthenticator.shared.displayStrings.resetPasswordButtonTitle, accessibilityTrait: .link) + cell.actionHandler = { [weak self] in + guard let self = self else { + return + } + + self.tracker.track(click: .forgottenPassword) + + // If information is currently processing, ignore button tap. + guard self.enableSubmit(animating: false) else { + return + } + + WordPressAuthenticator.openForgotPasswordURL(self.loginFields) + } + } + + /// Configure the error message cell. + /// + func configureErrorLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: errorMessage, style: .error) + if shouldChangeVoiceOverFocus { + UIAccessibility.post(notification: .layoutChanged, argument: cell) + } + } + + /// Configure the view for an editing state. + /// + func configureViewForEditingIfNeeded() { + // Check the helper to determine whether an editing state should be assumed. + adjustViewForKeyboard(SigninEditingState.signinEditingStateActive) + if SigninEditingState.signinEditingStateActive { + usernameField?.becomeFirstResponder() + } + } + + /// Presents verify email instructions screen + /// + /// - Parameters: + /// - loginFields: `LoginFields` instance created using `makeLoginFieldsUsing` helper method + /// + func presentVerifyEmail(loginFields: LoginFields) { + guard let vc = VerifyEmailViewController.instantiate(from: .verifyEmail) else { + WPAuthenticatorLogError("Failed to navigate from SiteCredentialsViewController to VerifyEmailViewController") + return + } + + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + + /// Used for creating `LoginFields` + /// + /// - Parameters: + /// - xmlrpc: XML-RPC URL as a String + /// - options: Dictionary received from .org site credential authentication response. (Containing `jetpack_user_email` and `home_url` values) + /// + /// - Returns: A valid `LoginFields` instance or `nil` + /// + func makeLoginFieldsUsing(xmlrpc: String, options: [AnyHashable: Any]) -> LoginFields? { + guard let xmlrpcURL = URL(string: xmlrpc) else { + WPAuthenticatorLogError("Failed to initiate XML-RPC URL from \(xmlrpc)") + return nil + } + + // `jetpack_user_email` to be used for WPCOM login + guard let email = options["jetpack_user_email"] as? [String: Any], + let userName = email["value"] as? String else { + WPAuthenticatorLogError("Failed to find jetpack_user_email value.") + return nil + } + + // Site address + guard let home_url = options["home_url"] as? [String: Any], + let siteAddress = home_url["value"] as? String else { + WPAuthenticatorLogError("Failed to find home_url value.") + return nil + } + + let loginFields = LoginFields() + loginFields.meta.xmlrpcURL = xmlrpcURL as NSURL + loginFields.username = userName + loginFields.siteAddress = siteAddress + return loginFields + } + + func validateFormAndTriggerDelegate() { + view.endEditing(true) + displayError(message: "") + + // Is everything filled out? + if !loginFields.validateFieldsPopulatedForSignin() { + let errorMsg = NSLocalizedString("Please fill out all the fields", + comment: "A short prompt asking the user to properly fill out all login fields.") + displayError(message: errorMsg) + + return + } + + configureViewLoading(true) + + guard let delegate = WordPressAuthenticator.shared.delegate else { + fatalError("Error: Where did the delegate go?") + } + // manually construct the XMLRPC since this is needed to get the site address later + let xmlrpc = loginFields.siteAddress + "/xmlrpc.php" + let wporg = WordPressOrgCredentials(username: loginFields.username, + password: loginFields.password, + xmlrpc: xmlrpc, + options: [:]) + delegate.handleSiteCredentialLogin(credentials: wporg, onLoading: { [weak self] shouldShowLoading in + self?.configureViewLoading(shouldShowLoading) + }, onSuccess: { [weak self] in + self?.finishedLogin(withUsername: wporg.username, + password: wporg.password, + xmlrpc: wporg.xmlrpc, + options: wporg.options) + }, onFailure: { [weak self] error, incorrectCredentials in + self?.handleLoginFailure(error: error, incorrectCredentials: incorrectCredentials) + }) + } + + func handleLoginFailure(error: Error, incorrectCredentials: Bool) { + configureViewLoading(false) + guard configuration.enableManualErrorHandlingForSiteCredentialLogin == false else { + WordPressAuthenticator.shared.delegate?.handleSiteCredentialLoginFailure(error: error, for: loginFields.siteAddress, in: self) + return + } + if incorrectCredentials { + let message = NSLocalizedString("It looks like this username/password isn't associated with this site.", + comment: "An error message shown during log in when the username or password is incorrect.") + displayError(message: message, moveVoiceOverFocus: true) + } else { + displayError(error, sourceTag: sourceTag) + } + } + + func syncDataOrPresentWPComLogin(with wporgCredentials: WordPressOrgCredentials) { + if configuration.isWPComLoginRequiredForSiteCredentialsLogin { + presentWPComLogin(wporgCredentials: wporgCredentials) + return + } + // Client didn't explicitly ask for WPCOM credentials. (`isWPComLoginRequiredForSiteCredentialsLogin` is false) + // So, sync the available credentials and finish sign in. + // + let credentials = AuthenticatorCredentials(wporg: wporgCredentials) + WordPressAuthenticator.shared.delegate?.sync(credentials: credentials) { [weak self] in + NotificationCenter.default.post(name: Foundation.Notification.Name(rawValue: WordPressAuthenticator.WPSigninDidFinishNotification), object: nil) + self?.showLoginEpilogue(for: credentials) + } + } + + func presentWPComLogin(wporgCredentials: WordPressOrgCredentials) { + // Try to get the jetpack email from XML-RPC response dictionary. + // + guard let loginFields = makeLoginFieldsUsing(xmlrpc: wporgCredentials.xmlrpc, + options: wporgCredentials.options) else { + WPAuthenticatorLogError("Unexpected response from .org site credentials sign in using XMLRPC.") + let credentials = AuthenticatorCredentials(wporg: wporgCredentials) + showLoginEpilogue(for: credentials) + return + } + + // Present verify email instructions screen. Passing loginFields will prefill the jetpack email in `VerifyEmailViewController` + // + presentVerifyEmail(loginFields: loginFields) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row { + case instructions + case username + case password + case forgotPassword + case errorMessage + + var reuseIdentifier: String { + switch self { + case .instructions: + return TextLabelTableViewCell.reuseIdentifier + case .username: + return TextFieldTableViewCell.reuseIdentifier + case .password: + return TextFieldTableViewCell.reuseIdentifier + case .forgotPassword: + return TextLinkButtonTableViewCell.reuseIdentifier + case .errorMessage: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} + +// MARK: - Instance Methods +/// Implementation methods copied from LoginSelfHostedViewController. +/// +extension SiteCredentialsViewController { + /// Sanitize and format the site address we show to users. + /// + @objc func sanitizedSiteAddress(siteAddress: String) -> String { + let baseSiteUrl = WordPressAuthenticator.baseSiteURL(string: siteAddress) as NSString + if let str = baseSiteUrl.components(separatedBy: "://").last { + return str + } + return siteAddress + } + + /// Validates what is entered in the various form fields and, if valid, + /// proceeds with the submit action. + /// + @objc func validateForm() { + guard configuration.enableManualSiteCredentialLogin else { + return validateFormAndLogin() // handles login with XMLRPC normally + } + + // asks the delegate to handle the login + validateFormAndTriggerDelegate() + } + + func finishedLogin(withUsername username: String, password: String, xmlrpc: String, options: [AnyHashable: Any]) { + let wporg = WordPressOrgCredentials(username: username, password: password, xmlrpc: xmlrpc, options: options) + /// If `completionHandler` is available, return early with the credentials. + if let completionHandler = completionHandler { + completionHandler(wporg) + } else { + syncDataOrPresentWPComLogin(with: wporg) + } + } + + override func displayRemoteError(_ error: Error) { + configureViewLoading(false) + let err = error as NSError + if err.code == 403 { + let message = NSLocalizedString("It looks like this username/password isn't associated with this site.", + comment: "An error message shown during log in when the username or password is incorrect.") + displayError(message: message, moveVoiceOverFocus: true) + } else { + displayError(error, sourceTag: sourceTag) + } + } +} diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmail.storyboard b/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmail.storyboard new file mode 100644 index 000000000000..d844a3f5afe5 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmail.storyboard @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmailViewController.swift b/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmailViewController.swift new file mode 100644 index 000000000000..bfcc9309d3e2 --- /dev/null +++ b/WordPressAuthenticator/Sources/Unified Auth/View Related/VerifyEmail/VerifyEmailViewController.swift @@ -0,0 +1,259 @@ +import UIKit + +final class VerifyEmailViewController: LoginViewController { + + // MARK: - Properties + + @IBOutlet private weak var tableView: UITableView! + private var buttonViewController: NUXButtonViewController? + private let rows = Row.allCases + + // MARK: - View lifecycle + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = WordPressAuthenticator.shared.displayStrings.logInTitle + styleNavigationBar(forUnified: true) + + // Store default margin, and size table for the view. + defaultTableViewMargin = tableViewLeadingConstraint?.constant ?? 0 + setTableViewMargins(forWidth: view.frame.width) + + registerTableViewCells() + configureButtonViewController() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if isBeingPresentedInAnyWay { + tracker.track(step: .verifyEmailInstructions) + } else { + tracker.set(step: .verifyEmailInstructions) + } + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + super.prepare(for: segue, sender: sender) + + if let vc = segue.destination as? NUXButtonViewController { + buttonViewController = vc + } + } + + // MARK: - Overrides + + override var sourceTag: WordPressSupportSourceTag { + .verifyEmailInstructions + } + + /// Style individual ViewController backgrounds, for now. + /// + override func styleBackground() { + guard let unifiedBackgroundColor = WordPressAuthenticator.shared.unifiedStyle?.viewControllerBackgroundColor else { + return super.styleBackground() + } + + view.backgroundColor = unifiedBackgroundColor + } + + /// Style individual ViewController status bars. + /// + override var preferredStatusBarStyle: UIStatusBarStyle { + WordPressAuthenticator.shared.unifiedStyle?.statusBarStyle ?? WordPressAuthenticator.shared.style.statusBarStyle + } + + /// Customise loading state of view. + /// + override func configureViewLoading(_ loading: Bool) { + buttonViewController?.setTopButtonState(isLoading: loading, + isEnabled: !loading) + buttonViewController?.setBottomButtonState(isLoading: false, + isEnabled: !loading) + navigationItem.hidesBackButton = loading + } +} + +// MARK: - UITableViewDataSource +extension VerifyEmailViewController: UITableViewDataSource { + /// Returns the number of rows in a section. + /// + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + /// Configure cells delegate method. + /// + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let row = rows[indexPath.row] + let cell = tableView.dequeueReusableCell(withIdentifier: row.reuseIdentifier, for: indexPath) + configure(cell, for: row, at: indexPath) + + return cell + } +} + +// MARK: - Private Methods +private extension VerifyEmailViewController { + /// Configure bottom buttons. + /// + func configureButtonViewController() { + guard let buttonViewController = buttonViewController else { + return + } + + buttonViewController.hideShadowView() + + // Setup `Send email verification link` button + buttonViewController.setupTopButton(title: ButtonConfiguration.SendEmailVerificationLink.title, + isPrimary: true) { [weak self] in + self?.handleSendEmailVerificationLinkButtonTapped() + } + + // Setup `Login with account password` button + buttonViewController.setupBottomButton(title: ButtonConfiguration.LoginWithAccountPassword.title, + isPrimary: false) { [weak self] in + self?.handleLoginWithAccountPasswordButtonTapped() + } + } + + // MARK: - Actions + @objc func handleSendEmailVerificationLinkButtonTapped() { + tracker.track(click: .requestMagicLink) + requestAuthenticationLink() + } + + @objc func handleLoginWithAccountPasswordButtonTapped() { + tracker.track(click: .loginWithAccountPassword) + presentUnifiedPassword() + } + + /// Registers all of the available TableViewCells. + /// + func registerTableViewCells() { + let cells = [ + GravatarEmailTableViewCell.reuseIdentifier: GravatarEmailTableViewCell.loadNib(), + TextLabelTableViewCell.reuseIdentifier: TextLabelTableViewCell.loadNib() + ] + + for (reuseIdentifier, nib) in cells { + tableView.register(nib, forCellReuseIdentifier: reuseIdentifier) + } + } + + /// Configure cells. + /// + func configure(_ cell: UITableViewCell, for row: Row, at indexPath: IndexPath) { + switch cell { + case let cell as GravatarEmailTableViewCell where row == .persona: + configureGravatarEmail(cell) + case let cell as TextLabelTableViewCell where row == .instructions: + configureInstructionLabel(cell) + case let cell as TextLabelTableViewCell where row == .typePassword: + configureTypePasswordButton(cell) + default: + WPAuthenticatorLogError("Error: Unidentified tableViewCell type found.") + } + } + + /// Configure the gravatar + email cell. + /// + func configureGravatarEmail(_ cell: GravatarEmailTableViewCell) { + cell.configure(withEmail: loginFields.username) + } + + /// Configure the instructions cell. + /// + func configureInstructionLabel(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.verifyMailLoginInstructions, + style: .body) + } + + /// Configure the enter password instructions cell. + /// + func configureTypePasswordButton(_ cell: TextLabelTableViewCell) { + cell.configureLabel(text: WordPressAuthenticator.shared.displayStrings.alternativelyEnterPasswordInstructions, + style: .body) + } + + /// Makes the call to request a magic authentication link be emailed to the user. + /// + func requestAuthenticationLink() { + loginFields.meta.emailMagicLinkSource = .login + + let email = loginFields.username + + configureViewLoading(true) + let service = WordPressComAccountService() + service.requestAuthenticationLink(for: email, + jetpackLogin: loginFields.meta.jetpackLogin, + success: { [weak self] in + self?.didRequestAuthenticationLink() + self?.configureViewLoading(false) + + }, failure: { [weak self] (error: Error) in + guard let self = self else { return } + + self.tracker.track(failure: error.localizedDescription) + + self.displayError(error, sourceTag: self.sourceTag) + self.configureViewLoading(false) + }) + } + + /// When a magic link successfully sends, navigate the user to the next step. + /// + func didRequestAuthenticationLink() { + guard let vc = LoginMagicLinkViewController.instantiate(from: .unifiedLoginMagicLink) else { + WPAuthenticatorLogError("Failed to navigate to LoginMagicLinkViewController from VerifyEmailViewController") + return + } + + vc.loginFields = loginFields + vc.loginFields.restrictToWPCom = true + navigationController?.pushViewController(vc, animated: true) + } + + /// Presents unified password screen + /// + func presentUnifiedPassword() { + guard let vc = PasswordViewController.instantiate(from: .password) else { + WPAuthenticatorLogError("Failed to navigate to PasswordViewController from VerifyEmailViewController") + return + } + vc.loginFields = loginFields + navigationController?.pushViewController(vc, animated: true) + } + + // MARK: - Private Constants + + /// Rows listed in the order they were created. + /// + enum Row: CaseIterable { + case persona + case instructions + case typePassword + + var reuseIdentifier: String { + switch self { + case .persona: + return GravatarEmailTableViewCell.reuseIdentifier + case .instructions, .typePassword: + return TextLabelTableViewCell.reuseIdentifier + } + } + } +} + +// MARK: - Button configuration +private extension VerifyEmailViewController { + enum ButtonConfiguration { + enum SendEmailVerificationLink { + static let title = WordPressAuthenticator.shared.displayStrings.sendEmailVerificationLinkButtonTitle + } + + enum LoginWithAccountPassword { + static let title = WordPressAuthenticator.shared.displayStrings.loginWithAccountPasswordButtonTitle + } + } +} diff --git a/WordPressAuthenticator/Sources/WordPressAuthenticator.h b/WordPressAuthenticator/Sources/WordPressAuthenticator.h new file mode 100644 index 000000000000..cdc1ac7b1b87 --- /dev/null +++ b/WordPressAuthenticator/Sources/WordPressAuthenticator.h @@ -0,0 +1,20 @@ +#import + +//! Project version number for WordPressAuthenticator. +FOUNDATION_EXPORT double WordPressAuthenticatorVersionNumber; + +//! Project version string for WordPressAuthenticator. +FOUNDATION_EXPORT const unsigned char WordPressAuthenticatorVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + +#import +#import + +#import +#import +#import +#import +#import +#import diff --git a/WordPressAuthenticator/Tests/Analytics/AnalyticsTrackerTests.swift b/WordPressAuthenticator/Tests/Analytics/AnalyticsTrackerTests.swift new file mode 100644 index 000000000000..cf1e19fe99ae --- /dev/null +++ b/WordPressAuthenticator/Tests/Analytics/AnalyticsTrackerTests.swift @@ -0,0 +1,295 @@ +import XCTest +@testable import WordPressAuthenticator + +class AnalyticsTrackerTests: XCTestCase { + + // MARK: - Expectations: Building the properties dictionary + + private func expectedProperties(source: AuthenticatorAnalyticsTracker.Source, flow: AuthenticatorAnalyticsTracker.Flow, step: AuthenticatorAnalyticsTracker.Step) -> [String: String] { + + return [ + AuthenticatorAnalyticsTracker.Property.source.rawValue: source.rawValue, + AuthenticatorAnalyticsTracker.Property.flow.rawValue: flow.rawValue, + AuthenticatorAnalyticsTracker.Property.step.rawValue: step.rawValue + ] + } + + private func expectedProperties(source: AuthenticatorAnalyticsTracker.Source, flow: AuthenticatorAnalyticsTracker.Flow, step: AuthenticatorAnalyticsTracker.Step, failure: String) -> [String: String] { + + var properties = expectedProperties(source: source, flow: flow, step: step) + properties[AuthenticatorAnalyticsTracker.Property.failure.rawValue] = failure + + return properties + } + + private func expectedProperties(source: AuthenticatorAnalyticsTracker.Source, flow: AuthenticatorAnalyticsTracker.Flow, step: AuthenticatorAnalyticsTracker.Step, click: AuthenticatorAnalyticsTracker.ClickTarget) -> [String: String] { + + var properties = expectedProperties(source: source, flow: flow, step: step) + properties[AuthenticatorAnalyticsTracker.Property.click.rawValue] = click.rawValue + + return properties + } + + /// Test that when tracking an event through the AnalyticsTracker, the backing analytics tracker + /// receives a matching event. + /// + func testBackingTracker() { + let source = AuthenticatorAnalyticsTracker.Source.reauthentication + let flow = AuthenticatorAnalyticsTracker.Flow.loginWithGoogle + let step = AuthenticatorAnalyticsTracker.Step.start + + let expectedEventName = AuthenticatorAnalyticsTracker.EventType.step.rawValue + let expectedEventProperties = self.expectedProperties(source: source, flow: flow, step: step) + let trackingIsOk = expectation(description: "The parameters of the tracking call are as expected") + + let track = { (event: AnalyticsEvent) in + if event.name == expectedEventName + && event.properties == expectedEventProperties { + + trackingIsOk.fulfill() + } + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + tracker.set(flow: flow) + tracker.track(step: step) + + waitForExpectations(timeout: 0.1) + } + + /// Test that tracking a failure maintains the source, flow and step from the previously recorded step. + /// + /// Ref: pbArwn-I6-p2 + /// + func testFailure() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flow = AuthenticatorAnalyticsTracker.Flow.loginWithGoogle + let step = AuthenticatorAnalyticsTracker.Step.start + let failure = "some error" + + let expectedEventName = AuthenticatorAnalyticsTracker.EventType.failure.rawValue + let expectedEventProperties = self.expectedProperties(source: source, flow: flow, step: step, failure: failure) + let trackingIsOk = expectation(description: "The parameters of the tracking call are as expected") + + let track = { (event: AnalyticsEvent) in + // We'll ignore the first event and only check the properties from the failure. + if event.name == expectedEventName + && event.properties == expectedEventProperties { + + trackingIsOk.fulfill() + } + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + tracker.set(flow: flow) + tracker.track(step: step) + tracker.track(failure: failure) + + waitForExpectations(timeout: 0.1) + } + + /// Test that tracking a click maintains the source, flow and step from the previously recorded step. + /// + /// Ref: pbArwn-I6-p2 + /// + func testClick() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flow = AuthenticatorAnalyticsTracker.Flow.loginWithGoogle + let step = AuthenticatorAnalyticsTracker.Step.start + let click = AuthenticatorAnalyticsTracker.ClickTarget.dismiss + + let expectedEventName = AuthenticatorAnalyticsTracker.EventType.interaction.rawValue + let expectedEventProperties = self.expectedProperties(source: source, flow: flow, step: step, click: click) + let trackingIsOk = expectation(description: "The parameters of the tracking call are as expected") + + let track = { (event: AnalyticsEvent) in + // We'll ignore the first event and only check the properties from the failure. + if event.name == expectedEventName + && event.properties == expectedEventProperties { + + trackingIsOk.fulfill() + } + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + tracker.set(flow: flow) + tracker.track(step: step) + tracker.track(click: click) + + waitForExpectations(timeout: 0.1) + } + + // MARK: - Legacy Tracking Support Tests + + /// Tests legacy tracking for a step + /// + func testStepLegacyTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + let step = AuthenticatorAnalyticsTracker.Step.start + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + XCTFail() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: false, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(step: step, ifTrackingNotEnabled: { + legacyTrackingExecuted.fulfill() + }) + } + + waitForExpectations(timeout: 0.1) + } + + /// Tests the new tracking for a step + /// + func testStepNewTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + let step = AuthenticatorAnalyticsTracker.Step.start + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + legacyTrackingExecuted.fulfill() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(step: step, ifTrackingNotEnabled: { + XCTFail() + }) + } + + waitForExpectations(timeout: 0.1) + } + + /// Tests legacy tracking for a click interaction + /// + func testClickLegacyTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + let click = AuthenticatorAnalyticsTracker.ClickTarget.connectSite + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + XCTFail() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: false, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(click: click, ifTrackingNotEnabled: { + legacyTrackingExecuted.fulfill() + }) + } + + waitForExpectations(timeout: 0.1) + } + + /// Tests the new tracking for a click interaction + /// + func testClickNewTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + let click = AuthenticatorAnalyticsTracker.ClickTarget.connectSite + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + legacyTrackingExecuted.fulfill() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(click: click, ifTrackingNotEnabled: { + XCTFail() + }) + } + + waitForExpectations(timeout: 0.1) + } + + /// Tests legacy tracking for a failure + /// + func testFailureLegacyTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + XCTFail() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: false, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(failure: "error", ifTrackingNotEnabled: { + legacyTrackingExecuted.fulfill() + }) + } + + waitForExpectations(timeout: 0.1) + } + + /// Tests the new tracking for a failure + /// + func testFailureNewTracking() { + let source = AuthenticatorAnalyticsTracker.Source.default + let flows: [AuthenticatorAnalyticsTracker.Flow] = [.loginWithApple, .signupWithApple, .loginWithGoogle, .signupWithGoogle, .loginWithSiteAddress] + + let legacyTrackingExecuted = expectation(description: "The legacy tracking block was executed.") + legacyTrackingExecuted.expectedFulfillmentCount = flows.count + + let track = { (_: AnalyticsEvent) in + legacyTrackingExecuted.fulfill() + } + + let tracker = AuthenticatorAnalyticsTracker(enabled: true, track: track) + + tracker.set(source: source) + + for flow in flows { + tracker.set(flow: flow) + tracker.track(failure: "error", ifTrackingNotEnabled: { + XCTFail() + }) + } + + waitForExpectations(timeout: 0.1) + } +} diff --git a/WordPressAuthenticator/Tests/Authenticator/PasteboardTests.swift b/WordPressAuthenticator/Tests/Authenticator/PasteboardTests.swift new file mode 100644 index 000000000000..246cba095f47 --- /dev/null +++ b/WordPressAuthenticator/Tests/Authenticator/PasteboardTests.swift @@ -0,0 +1,63 @@ +import XCTest + +class PasteboardTests: XCTestCase { + let timeout = TimeInterval(3) + + override class func tearDown() { + super.tearDown() + let pasteboard = UIPasteboard.general + pasteboard.string = "" + } + + func testNominalAuthCode() throws { + if #available(iOS 16.0, *) { + throw XCTSkip("UIPasteboard doesn't work in iOS 16.0.") // Check https://github.com/wordpress-mobile/WordPressAuthenticator-iOS/issues/696 + } + + guard #available(iOS 14.0, *) else { + throw XCTSkip("Unsupported iOS version") + } + + let expect = expectation(description: "Could read nominal auth code from pasteboard") + let pasteboard = UIPasteboard.general + pasteboard.string = "123456" + + UIPasteboard.general.detectAuthenticatorCode { result in + switch result { + case .success(let authenticationCode): + XCTAssertEqual(authenticationCode, "123456") + case .failure: + XCTAssert(false) + } + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testLeadingZeroInAuthCodePreserved() throws { + if #available(iOS 16.0, *) { + throw XCTSkip("UIPasteboard doesn't work in iOS 16.0.") // Check https://github.com/wordpress-mobile/WordPressAuthenticator-iOS/issues/696 + } + + guard #available(iOS 14.0, *) else { + throw XCTSkip("Unsupported iOS version") + } + + let expect = expectation(description: "Could read leading zero auth code from pasteboard") + let pasteboard = UIPasteboard.general + pasteboard.string = "012345" + + UIPasteboard.general.detectAuthenticatorCode { result in + switch result { + case .success(let authenticationCode): + XCTAssertEqual(authenticationCode, "012345") + case .failure: + XCTAssert(false) + } + expect.fulfill() + } + + waitForExpectations(timeout: timeout, handler: nil) + } +} diff --git a/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticator+TestsUtils.swift b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticator+TestsUtils.swift new file mode 100644 index 000000000000..556fd3711197 --- /dev/null +++ b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticator+TestsUtils.swift @@ -0,0 +1,51 @@ +@testable import WordPressAuthenticator + +extension WordPressAuthenticator { + + static func initializeForTesting() { + WordPressAuthenticator.initialize( + configuration: WordPressAuthenticatorConfiguration( + wpcomClientId: "a", + wpcomSecret: "b", + wpcomScheme: "c", + wpcomTermsOfServiceURL: URL(string: "https://w.org")!, + googleLoginClientId: "e", + googleLoginServerClientId: "f", + googleLoginScheme: "g", + userAgent: "h" + ), + style: WordPressAuthenticatorStyle( + primaryNormalBackgroundColor: .red, + primaryNormalBorderColor: .none, + primaryHighlightBackgroundColor: .orange, + primaryHighlightBorderColor: .none, + secondaryNormalBackgroundColor: .yellow, + secondaryNormalBorderColor: .green, + secondaryHighlightBackgroundColor: .blue, + secondaryHighlightBorderColor: .systemIndigo, + disabledBackgroundColor: .purple, + disabledBorderColor: .red, + primaryTitleColor: .orange, + secondaryTitleColor: .yellow, + disabledTitleColor: .green, + disabledButtonActivityIndicatorColor: .blue, + textButtonColor: .systemIndigo, + textButtonHighlightColor: .purple, + instructionColor: .red, + subheadlineColor: .orange, + placeholderColor: .yellow, + viewControllerBackgroundColor: .green, + textFieldBackgroundColor: .blue, + navBarImage: UIImage(), + navBarBadgeColor: .systemIndigo, + navBarBackgroundColor: .purple + ), + unifiedStyle: .none, + displayImages: WordPressAuthenticatorDisplayImages( + magicLink: UIImage(), + siteAddressModalPlaceholder: UIImage() + ), + displayStrings: WordPressAuthenticatorDisplayStrings() + ) + } +} diff --git a/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorDisplayTextTests.swift b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorDisplayTextTests.swift new file mode 100644 index 000000000000..5773a045a4d8 --- /dev/null +++ b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorDisplayTextTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import WordPressAuthenticator + +// MARK: - WordPressAuthenticator Display Text Unit Tests +// +class WordPressAuthenticatorDisplayTextTests: XCTestCase { + /// Default display text instance + /// + let displayTextDefaults = WordPressAuthenticatorDisplayStrings.defaultStrings + + /// Verifies that values in defaultText are not nil + /// + func testThatDefaultTextValuesAreNotNil() { + XCTAssertNotNil(displayTextDefaults.emailLoginInstructions) + XCTAssertNotNil(displayTextDefaults.siteLoginInstructions) + } + + /// Verifies that values in defaultText are not empty strings + /// + func testThatDefaultTextValuesAreNotEmpty() { + XCTAssertFalse(displayTextDefaults.emailLoginInstructions.isEmpty) + XCTAssertFalse(displayTextDefaults.siteLoginInstructions.isEmpty) + } +} diff --git a/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorTests.swift b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorTests.swift new file mode 100644 index 000000000000..b444d0be9421 --- /dev/null +++ b/WordPressAuthenticator/Tests/Authenticator/WordPressAuthenticatorTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import WordPressAuthenticator + +// MARK: - WordPressAuthenticator Unit Tests +// +class WordPressAuthenticatorTests: XCTestCase { + let timeout = TimeInterval(3) + + override class func setUp() { + super.setUp() + + WordPressAuthenticator.initialize( + configuration: WordpressAuthenticatorProvider.wordPressAuthenticatorConfiguration(), + style: WordpressAuthenticatorProvider.wordPressAuthenticatorStyle(.random), + unifiedStyle: WordpressAuthenticatorProvider.wordPressAuthenticatorUnifiedStyle(.random) + ) + } + + func testBaseSiteURL() { + var baseURL = "testsite.wordpress.com" + var url = WordPressAuthenticator.baseSiteURL(string: "http://\(baseURL)") + XCTAssert(url == "https://\(baseURL)", "Should force https for a wpcom site having http.") + + url = WordPressAuthenticator.baseSiteURL(string: baseURL) + XCTAssert(url == "https://\(baseURL)", "Should force https for a wpcom site without a scheme.") + + baseURL = "www.selfhostedsite.com" + url = WordPressAuthenticator.baseSiteURL(string: baseURL) + XCTAssert((url == "https://\(baseURL)"), "Should add https:\\ for a non wpcom site missing a scheme.") + + url = WordPressAuthenticator.baseSiteURL(string: "\(baseURL)/wp-login.php") + XCTAssert((url == "https://\(baseURL)"), "Should remove wp-login.php from the path.") + + url = WordPressAuthenticator.baseSiteURL(string: "\(baseURL)/wp-admin") + XCTAssert((url == "https://\(baseURL)"), "Should remove /wp-admin from the path.") + + url = WordPressAuthenticator.baseSiteURL(string: "\(baseURL)/wp-admin/") + XCTAssert((url == "https://\(baseURL)"), "Should remove /wp-admin/ from the path.") + + url = WordPressAuthenticator.baseSiteURL(string: "\(baseURL)/") + XCTAssert((url == "https://\(baseURL)"), "Should remove a trailing slash from the url.") + + // Check non-latin characters and puny code + baseURL = "http://例.例" + let punycode = "http://xn--fsq.xn--fsq" + url = WordPressAuthenticator.baseSiteURL(string: baseURL) + XCTAssert(url == punycode) + url = WordPressAuthenticator.baseSiteURL(string: punycode) + XCTAssert(url == punycode) + } + + func testBaseSiteURLKeepsHTTPSchemeForNonWPSites() { + let url = "http://selfhostedsite.com" + let correctedURL = WordPressAuthenticator.baseSiteURL(string: url) + XCTAssertEqual(correctedURL, url) + } + + // MARK: WordPressAuthenticator Notification Tests + func testDispatchesSupportPushNotificationReceived() { + let authenticator = WordpressAuthenticatorProvider.getWordpressAuthenticator() + _ = expectation(forNotification: .wordpressSupportNotificationReceived, object: nil, handler: nil) + + authenticator.supportPushNotificationReceived() + + waitForExpectations(timeout: timeout, handler: nil) + } + + func testDispatchesSupportPushNotificationCleared() { + let authenticator = WordpressAuthenticatorProvider.getWordpressAuthenticator() + _ = expectation(forNotification: .wordpressSupportNotificationCleared, object: nil, handler: nil) + + authenticator.supportPushNotificationCleared() + + waitForExpectations(timeout: timeout, handler: nil) + } + + // MARK: View Tests + func testWordpressAuthIsAuthenticationViewController() { + let loginViewcontroller = LoginViewController() + let nuxViewController = NUXViewController() + let nuxTableViewController = NUXTableViewController() + let basicViewController = UIViewController() + + XCTAssertTrue(WordPressAuthenticator.isAuthenticationViewController(loginViewcontroller)) + XCTAssertTrue(WordPressAuthenticator.isAuthenticationViewController(nuxViewController)) + XCTAssertTrue(WordPressAuthenticator.isAuthenticationViewController(nuxTableViewController)) + XCTAssertFalse(WordPressAuthenticator.isAuthenticationViewController(basicViewController)) + } + + func testShowLoginFromPresenterReturnsLoginInitialVC() { + let presenterSpy = ModalViewControllerPresentingSpy() + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(block: { (_, _) -> Bool in + return presenterSpy.presentedVC != nil + }), object: .none) + + WordPressAuthenticator.showLoginFromPresenter(presenterSpy, animated: true) + wait(for: [expectation], timeout: timeout) + + XCTAssertTrue(presenterSpy.presentedVC is LoginNavigationController) + } + + func testShowLoginForJustWPComPresentsCorrectVC() { + let presenterSpy = ModalViewControllerPresentingSpy() + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(block: { (_, _) -> Bool in + return presenterSpy.presentedVC != nil + }), object: .none) + + WordPressAuthenticator.showLoginForJustWPCom(from: presenterSpy) + wait(for: [expectation], timeout: timeout) + + XCTAssertTrue(presenterSpy.presentedVC is LoginNavigationController) + } + + func testSignInForWPOrgReturnsVC() { + let vc = WordPressAuthenticator.signinForWPOrg() + + XCTAssertTrue(vc is LoginSiteAddressViewController) + } + + func testShowLoginForJustWPComSetsMetaProperties() throws { + let presenterSpy = ModalViewControllerPresentingSpy() + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(block: { (_, _) -> Bool in + return presenterSpy.presentedVC != nil + }), object: .none) + + WordPressAuthenticator.showLoginForJustWPCom(from: presenterSpy, + jetpackLogin: false, + connectedEmail: "email-address@example.com") + + let navController = try XCTUnwrap(presenterSpy.presentedVC as? LoginNavigationController) + let controller = try XCTUnwrap(navController.viewControllers.first as? LoginEmailViewController) + + wait(for: [expectation], timeout: timeout) + + XCTAssertEqual(controller.loginFields.restrictToWPCom, true) + XCTAssertEqual(controller.loginFields.username, "email-address@example.com") + } + + func testShowLoginForSelfHostedSitePresentsCorrectVC() { + let presenterSpy = ModalViewControllerPresentingSpy() + let expectation = XCTNSPredicateExpectation(predicate: NSPredicate(block: { (_, _) -> Bool in + return presenterSpy.presentedVC != nil + }), object: .none) + + WordPressAuthenticator.showLoginForSelfHostedSite(presenterSpy) + wait(for: [expectation], timeout: timeout) + + XCTAssertTrue(presenterSpy.presentedVC is LoginNavigationController) + } + + func testSignInForWPComWithLoginFieldsReturnsVC() throws { + let navController = try XCTUnwrap(WordPressAuthenticator.signinForWPCom(dotcomEmailAddress: "example@email.com", dotcomUsername: "username") as? UINavigationController) + let vc = navController.topViewController + + XCTAssertTrue(vc is LoginWPComViewController) + } + + func testSignInForWPComSetsEmptyLoginFields() throws { + let navController = try XCTUnwrap(WordPressAuthenticator.signinForWPCom(dotcomEmailAddress: nil, dotcomUsername: nil) as? UINavigationController) + let vc = try XCTUnwrap(navController.topViewController as? LoginWPComViewController) + + XCTAssertEqual(vc.loginFields.emailAddress, "") + XCTAssertEqual(vc.loginFields.username, "") + } + + // MARK: WordPressAuthenticator URL verification Tests + func testIsGoogleAuthURL() { + let authenticator = WordpressAuthenticatorProvider.getWordpressAuthenticator() + let googleURL = URL(string: "com.googleuserconsent.apps/82ekn2932nub23h23hn3")! + let magicLinkURL = URL(string: "https://magic-login")! + let wordpressComURL = URL(string: "https://WordPress.com")! + + XCTAssertTrue(authenticator.isGoogleAuthUrl(googleURL)) + XCTAssertFalse(authenticator.isGoogleAuthUrl(magicLinkURL)) + XCTAssertFalse(authenticator.isGoogleAuthUrl(wordpressComURL)) + } + + func testIsWordPressAuthURL() { + let authenticator = WordpressAuthenticatorProvider.getWordpressAuthenticator() + let magicLinkURL = URL(string: "https://magic-login")! + let googleURL = URL(string: "https://google.com")! + let wordpressComURL = URL(string: "https://WordPress.com")! + + XCTAssertTrue(authenticator.isWordPressAuthUrl(magicLinkURL)) + XCTAssertFalse(authenticator.isWordPressAuthUrl(googleURL)) + XCTAssertFalse(authenticator.isWordPressAuthUrl(wordpressComURL)) + } + + func testHandleWordPressAuthURLReturnsTrueOnSuccess() { + let authenticator = WordpressAuthenticatorProvider.getWordpressAuthenticator() + let url = URL(string: "https://wordpress.com/wp-login.php?token=1234567890%26action&magic-login&sr=1&signature=1234567890oienhdtsra&flow=signup") + + XCTAssertTrue(authenticator.handleWordPressAuthUrl(url!, rootViewController: UIViewController(), automatedTesting: true)) + } +} diff --git a/WordPressAuthenticator/Tests/Authenticator/WordPressSourceTagTests.swift b/WordPressAuthenticator/Tests/Authenticator/WordPressSourceTagTests.swift new file mode 100644 index 000000000000..d2c7d3a38834 --- /dev/null +++ b/WordPressAuthenticator/Tests/Authenticator/WordPressSourceTagTests.swift @@ -0,0 +1,131 @@ +import XCTest +import WordPressAuthenticator + +class WordPressSourceTagTests: XCTestCase { + + func testGeneralLoginSourceTag() { + let tag = WordPressSupportSourceTag.generalLogin + + XCTAssertEqual(tag.name, "generalLogin") + XCTAssertEqual(tag.origin, "origin:login-screen") + } + + func testJetpackLoginSourceTag() { + let tag = WordPressSupportSourceTag.jetpackLogin + + XCTAssertEqual(tag.name, "jetpackLogin") + XCTAssertEqual(tag.origin, "origin:jetpack-login-screen") + } + + func testLoginEmailSourceTag() { + let tag = WordPressSupportSourceTag.loginEmail + + XCTAssertEqual(tag.name, "loginEmail") + XCTAssertEqual(tag.origin, "origin:login-email") + } + + func testLoginAppleSourceTag() { + let tag = WordPressSupportSourceTag.loginApple + + XCTAssertEqual(tag.name, "loginApple") + XCTAssertEqual(tag.origin, "origin:login-apple") + } + + func testlogin2FASourceTag() { + let tag = WordPressSupportSourceTag.login2FA + + XCTAssertEqual(tag.name, "login2FA") + XCTAssertEqual(tag.origin, "origin:login-2fa") + } + + func testLoginMagicLinkSourceTag() { + let tag = WordPressSupportSourceTag.loginMagicLink + + XCTAssertEqual(tag.name, "loginMagicLink") + XCTAssertEqual(tag.origin, "origin:login-magic-link") + } + + func testSiteAddressSourceTag() { + let tag = WordPressSupportSourceTag.loginSiteAddress + + XCTAssertEqual(tag.name, "loginSiteAddress") + XCTAssertEqual(tag.origin, "origin:login-site-address") + } + + func testVerifyEmailInstructionsSourceTag() { + let tag = WordPressSupportSourceTag.verifyEmailInstructions + + XCTAssertEqual(tag.name, "verifyEmailInstructions") + XCTAssertEqual(tag.origin, "origin:login-site-address") + } + + func testLoginUsernameSourceTag() { + let tag = WordPressSupportSourceTag.loginUsernamePassword + + XCTAssertEqual(tag.name, "loginUsernamePassword") + XCTAssertEqual(tag.origin, "origin:login-username-password") + } + + func testLoginUsernamePasswordSourceTag() { + let tag = WordPressSupportSourceTag.loginWPComUsernamePassword + + XCTAssertEqual(tag.name, "loginWPComUsernamePassword") + XCTAssertEqual(tag.origin, "origin:wpcom-login-username-password") + } + + func testLoginWPComPasswordSourceTag() { + let tag = WordPressSupportSourceTag.loginWPComPassword + + XCTAssertEqual(tag.name, "loginWPComPassword") + XCTAssertEqual(tag.origin, "origin:login-wpcom-password") + } + + func testWPComSignupEmailSourceTag() { + let tag = WordPressSupportSourceTag.wpComSignupEmail + + XCTAssertEqual(tag.name, "wpComSignupEmail") + XCTAssertEqual(tag.origin, "origin:wpcom-signup-email-entry") + } + + func testWPComSignupSourceTag() { + let tag = WordPressSupportSourceTag.wpComSignup + + XCTAssertEqual(tag.name, "wpComSignup") + XCTAssertEqual(tag.origin, "origin:signup-screen") + } + + func testWPComSignupWaitingForGoogleSourceTag() { + let tag = WordPressSupportSourceTag.wpComSignupWaitingForGoogle + + XCTAssertEqual(tag.name, "wpComSignupWaitingForGoogle") + XCTAssertEqual(tag.origin, "origin:signup-waiting-for-google") + } + + func testWPComAuthGoogleSignupWaitingForGoogleSourceTag() { + let tag = WordPressSupportSourceTag.wpComAuthWaitingForGoogle + + XCTAssertEqual(tag.name, "wpComAuthWaitingForGoogle") + XCTAssertEqual(tag.origin, "origin:auth-waiting-for-google") + } + + func testWPComAuthGoogleSignupConfirmationSourceTag() { + let tag = WordPressSupportSourceTag.wpComAuthGoogleSignupConfirmation + + XCTAssertEqual(tag.name, "wpComAuthGoogleSignupConfirmation") + XCTAssertEqual(tag.origin, "origin:auth-google-signup-confirmation") + } + + func testWPComSignupMagicLinkSourceTag() { + let tag = WordPressSupportSourceTag.wpComSignupMagicLink + + XCTAssertEqual(tag.name, "wpComSignupMagicLink") + XCTAssertEqual(tag.origin, "origin:signup-magic-link") + } + + func testWPComSignupAppleSourceTag() { + let tag = WordPressSupportSourceTag.wpComSignupApple + + XCTAssertEqual(tag.name, "wpComSignupApple") + XCTAssertEqual(tag.origin, "origin:signup-apple") + } +} diff --git a/WordPressAuthenticator/Tests/Credentials/CredentialsTests.swift b/WordPressAuthenticator/Tests/Credentials/CredentialsTests.swift new file mode 100644 index 000000000000..a15824123be4 --- /dev/null +++ b/WordPressAuthenticator/Tests/Credentials/CredentialsTests.swift @@ -0,0 +1,128 @@ +import XCTest +@testable import WordPressAuthenticator + +class CredentialsTests: XCTestCase { + + let token = "arstdhneio123456789qwfpgjluy" + let siteURL = "https://example.com" + let username = "user123" + let password = "arstdhneio" + let xmlrpc = "https://example.com/xmlrpc.php" + + func testWordpressComCredentialsInit() { + let wpcomCredentials = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: siteURL) + + XCTAssertEqual(wpcomCredentials.authToken, token) + XCTAssertEqual(wpcomCredentials.isJetpackLogin, false) + XCTAssertEqual(wpcomCredentials.multifactor, false) + XCTAssertEqual(wpcomCredentials.siteURL, siteURL) + } + + func testWordPressComCredentialsSiteURLReturnsDefaultValue() { + let wpcomCredentials = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: "") + + let expected = "https://wordpress.com" + + XCTAssertEqual(wpcomCredentials.siteURL, expected) + } + + func testWordPressComCredentialsEquatableReturnsCorrectValue() { + let credential = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: siteURL) + let match = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: siteURL) + let differentJetpack = WordPressComCredentials(authToken: token, + isJetpackLogin: true, + multifactor: false, + siteURL: siteURL) + let differentMultifactor = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: true, + siteURL: siteURL) + let differentSiteURL = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: "") + let differentAuthToken = WordPressComCredentials(authToken: "ARSTDBVCXZ(*&^%$", + isJetpackLogin: false, + multifactor: false, + siteURL: siteURL) + + XCTAssertEqual(credential, match) + XCTAssertEqual(credential, differentJetpack) + XCTAssertEqual(credential, differentMultifactor) + XCTAssertNotEqual(credential, differentSiteURL) + XCTAssertNotEqual(credential, differentAuthToken) + } + + func testWordpressOrgCredentialsInit() { + let wporgcredentials = WordPressOrgCredentials(username: username, + password: password, + xmlrpc: xmlrpc, + options: [:]) + + XCTAssertEqual(wporgcredentials.username, username) + XCTAssertEqual(wporgcredentials.password, password) + XCTAssertEqual(wporgcredentials.xmlrpc, xmlrpc) + } + + func testWordPressOrgCredentialsEquatable() { + let lhs = WordPressOrgCredentials(username: username, + password: password, + xmlrpc: xmlrpc, + options: [:]) + + let rhs = WordPressOrgCredentials(username: username, + password: password, + xmlrpc: xmlrpc, + options: [:]) + + XCTAssertTrue(lhs == rhs) + } + + func testWordPressOrgCredentialsNotEquatable() { + let lhs = WordPressOrgCredentials(username: username, + password: password, + xmlrpc: xmlrpc, + options: [:]) + + let rhs = WordPressOrgCredentials(username: "username5678", + password: password, + xmlrpc: xmlrpc, + options: [:]) + + XCTAssertFalse(lhs == rhs) + } + + func testAuthenticatorCredentialsInit() { + let wporgCredentials = WordPressOrgCredentials(username: username, + password: password, + xmlrpc: xmlrpc, + options: [:]) + let wpcomCredentials = WordPressComCredentials(authToken: token, + isJetpackLogin: false, + multifactor: false, + siteURL: siteURL) + let authenticatorCredentials = AuthenticatorCredentials(wpcom: wpcomCredentials, + wporg: wporgCredentials) + + XCTAssertEqual(authenticatorCredentials.wpcom?.authToken, token) + XCTAssertEqual(authenticatorCredentials.wpcom?.isJetpackLogin, false) + XCTAssertEqual(authenticatorCredentials.wpcom?.multifactor, false) + XCTAssertEqual(authenticatorCredentials.wpcom?.siteURL, siteURL) + XCTAssertEqual(authenticatorCredentials.wporg?.username, username) + XCTAssertEqual(authenticatorCredentials.wporg?.password, password) + XCTAssertEqual(authenticatorCredentials.wporg?.xmlrpc, xmlrpc) + } + +} diff --git a/WordPressAuthenticator/Tests/Email Client Picker/AppSelectorTests.swift b/WordPressAuthenticator/Tests/Email Client Picker/AppSelectorTests.swift new file mode 100644 index 000000000000..f16418db48aa --- /dev/null +++ b/WordPressAuthenticator/Tests/Email Client Picker/AppSelectorTests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import WordPressAuthenticator + +struct URLMocks { + + static let mockAppList = ["gmail": "googlemail://", "airmail": "airmail://"] +} + +class MockUrlHandler: URLHandler { + + var shouldOpenUrls = true + + var canOpenUrlExpectation: XCTestExpectation? + var openUrlExpectation: XCTestExpectation? + + func canOpenURL(_ url: URL) -> Bool { + canOpenUrlExpectation?.fulfill() + canOpenUrlExpectation = nil + return shouldOpenUrls + } + + func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)?) { + openUrlExpectation?.fulfill() + } +} + +class AppSelectorTests: XCTestCase { + + func testSelectorInitializationSuccess() { + // Given + let urlHandler = MockUrlHandler() + urlHandler.canOpenUrlExpectation = expectation(description: "canOpenUrl called") + // When + let appSelector = AppSelector(with: URLMocks.mockAppList, sourceView: UIView(), urlHandler: urlHandler) + // Then + XCTAssertNotNil(appSelector) + XCTAssertNotNil(appSelector?.alertController) + XCTAssertEqual(appSelector!.alertController.actions.count, 3) + waitForExpectations(timeout: 4) { error in + if let error = error { + XCTFail("waitForExpectationsWithTimeout errored: \(error)") + } + } + } + + func testSelectorInitializationFailsWithNoApps() { + // Given + let urlHandler = MockUrlHandler() + // When + let appSelector = AppSelector(with: [:], sourceView: UIView(), urlHandler: urlHandler) + // Then + XCTAssertNil(appSelector) + } + + func testSelectorInitializationFailsWithInvalidUrl() { + // Given + let urlHandler = MockUrlHandler() + urlHandler.canOpenUrlExpectation = expectation(description: "canOpenUrl called") + urlHandler.shouldOpenUrls = false + // When + let appSelector = AppSelector(with: URLMocks.mockAppList, sourceView: UIView(), urlHandler: urlHandler) + // Then + XCTAssertNil(appSelector) + waitForExpectations(timeout: 4) { error in + if let error = error { + XCTFail("waitForExpectationsWithTimeout errored: \(error)") + } + } + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/Character+URLSafeTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/Character+URLSafeTests.swift new file mode 100644 index 000000000000..99d85c71d98e --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/Character+URLSafeTests.swift @@ -0,0 +1,17 @@ +@testable import WordPressAuthenticator +import Foundation +import XCTest + +class Character_URLSafeTests: XCTestCase { + + func testURLSafeCharacters() throws { + let urlSafe = CharacterSet(Character.urlSafeCharacters.map { "\($0)" }.joined().unicodeScalars) + + // Ensure `Character.urlSafeCharacters` is a subset of `CharacterSet.urlQueryAllowed` + XCTAssertTrue(urlSafe.isStrictSubset(of: CharacterSet.urlQueryAllowed)) + + // Notice that `CharacterSet.urlQueryAllowed` is not a subset of + // `Character.urlSafeCharacters`, though, because URL queries allow characters such as &. + XCTAssertFalse(CharacterSet.urlQueryAllowed.isStrictSubset(of: urlSafe)) + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/CodeVerifier+Fixture.swift b/WordPressAuthenticator/Tests/GoogleSignIn/CodeVerifier+Fixture.swift new file mode 100644 index 000000000000..9d8a42a831cf --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/CodeVerifier+Fixture.swift @@ -0,0 +1,12 @@ +@testable import WordPressAuthenticator + +extension ProofKeyForCodeExchange.CodeVerifier { + + /// A code verifier for testing purposes that is guaranteed to be valid and deterministic. + /// + /// The reason we care about it being deterministic is because we don't want implicit randomness test. + /// The only place were we want to use random values in the `CodeVerifier` tests which explicitly check the random generation. + static func fixture() -> Self { + .init(value: (0.. String { + (0.. + + init(data: Data) { + self.init(result: .success(data)) + } + + init(error: Error) { + self.init(result: .failure(error)) + } + + init(result: Result) { + self.result = result + } + + func data(for request: URLRequest) async throws -> Data { + switch result { + case .success(let data): return data + case .failure(let error): throw error + } + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/GoogleClientIdTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleClientIdTests.swift new file mode 100644 index 000000000000..f4f73bc44ff2 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleClientIdTests.swift @@ -0,0 +1,23 @@ +@testable import WordPressAuthenticator +import XCTest + +class GoogleClientIdTests: XCTestCase { + + func testFailsInitIfNotAValidFormat() { + XCTAssertNil(GoogleClientId(string: "invalid")) + } + + func testDoesNotFailInitIfValidFormat() { + XCTAssertNotNil(GoogleClientId(string: "com.something.something")) + XCTAssertNotNil(GoogleClientId(string: "a.b.c")) + } + + func testRedirectURIGeneration() { + XCTAssertEqual(GoogleClientId(string: "a.b.c")?.redirectURI(path: .none), "c.b.a") + XCTAssertEqual(GoogleClientId(string: "a.b.c")?.redirectURI(path: "a_path"), "c.b.a:/a_path") + } + + func testDefaultRedirectURI() { + XCTAssertEqual(GoogleClientId(string: "a.b.c")?.defaultRedirectURI, "c.b.a:/oauth2callback") + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGetterTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGetterTests.swift new file mode 100644 index 000000000000..b17e2653e2af --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGetterTests.swift @@ -0,0 +1,50 @@ +@testable import WordPressAuthenticator +import XCTest + +class GoogleOAuthTokenGetterTests: XCTestCase { + + func testThrowsWhenReceivingAnError() async throws { + let dataGettingStub = DataGettingStub(error: TestError(id: 1)) + + let getter = GoogleOAuthTokenGetter(dataGetter: dataGettingStub) + + do { + _ = try await getter.getToken( + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + authCode: "abc", + pkce: ProofKeyForCodeExchange() + ) + XCTFail("Expected error to be thrown") + } catch { + let error = try XCTUnwrap(error as? TestError) + XCTAssertEqual(error.id, 1) + } + } + + func testReturnsTokenWhenReceivingOne() async throws { + let expectedResponse = OAuthTokenResponseBody( + accessToken: "a", + expiresIn: 1, + rawIDToken: .none, + refreshToken: .none, + scope: "s", + tokenType: "t" + ) + let dataGettingStub = DataGettingStub(data: try JSONEncoder().encode(expectedResponse)) + let getter = GoogleOAuthTokenGetter(dataGetter: dataGettingStub) + + let response = try await getter.getToken( + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + authCode: "abc", + pkce: ProofKeyForCodeExchange() + ) + + XCTAssertEqual(response, expectedResponse) + } +} + +struct TestError: Equatable, Error { + let id: Int +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGettingStub.swift b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGettingStub.swift new file mode 100644 index 000000000000..9e92ea826584 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/GoogleOAuthTokenGettingStub.swift @@ -0,0 +1,30 @@ +@testable import WordPressAuthenticator + +struct GoogleOAuthTokenGettingStub: GoogleOAuthTokenGetting { + + let result: Result + + init(response: OAuthTokenResponseBody) { + self.init(result: .success(response)) + } + + init(error: Error) { + self.init(result: .failure(error)) + } + + init(result: Result) { + self.result = result + } + + func getToken( + clientId: GoogleClientId, + audience: String, + authCode: String, + pkce: ProofKeyForCodeExchange + ) async throws -> OAuthTokenResponseBody { + switch result { + case .success(let response): return response + case .failure(let error): throw error + } + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/IDTokenTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/IDTokenTests.swift new file mode 100644 index 000000000000..d8acbe4214e3 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/IDTokenTests.swift @@ -0,0 +1,26 @@ +@testable import WordPressAuthenticator +import XCTest + +class IDTokenTests: XCTestCase { + + func testInitWithJWTWithoutNameNorEmailFails() throws { + XCTAssertNil(IDToken(jwt: try XCTUnwrap(JSONWebToken(encodedString: JSONWebToken.validJWTString)))) + } + + func testInitWithJWTWithoutEmailFails() throws { + XCTAssertNil(IDToken(jwt: try XCTUnwrap(JSONWebToken(encodedString: JSONWebToken.validJWTStringWithNameOnly)))) + } + + func testInitWithJWTWithoutNameFails() throws { + XCTAssertNil(IDToken(jwt: try XCTUnwrap(JSONWebToken(encodedString: JSONWebToken.validJWTStringWithEmailOnly)))) + } + + func testInitWithJWTWithNameAndEmailSucceeds() throws { + let jwt = try XCTUnwrap(JSONWebToken(encodedString: JSONWebToken.validJWTStringWithNameAndEmail)) + let token = try XCTUnwrap(IDToken(jwt: jwt)) + + XCTAssertEqual(token.name, JSONWebToken.nameFromValidJWTStringWithEmail) + XCTAssertEqual(token.email, JSONWebToken.emailFromValidJWTStringWithEmail) + } + +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/JSONWebToken+Fixtures.swift b/WordPressAuthenticator/Tests/GoogleSignIn/JSONWebToken+Fixtures.swift new file mode 100644 index 000000000000..6f5a01b6642a --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/JSONWebToken+Fixtures.swift @@ -0,0 +1,58 @@ +@testable import WordPressAuthenticator + +extension JSONWebToken { + + // Created with https://jwt.io/ with input: + // + // header: { + // "alg": "HS256", + // "typ": "JWT" + // } + // payload: { + // "key": "value", + // "other_key": "other_value" + // } + private(set) static var validJWTString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsIm90aGVyX2tleSI6Im90aGVyX3ZhbHVlIn0.Koc07zTGuATtQK7EvfAuwgZ-Nsr6P6J3HV4h3QLlXpM" + + // Created with https://jwt.io/ with input: + // + // header: { + // "alg": "HS256", + // "typ": "JWT" + // } + // payload: { + // "key": "value", + // "email": "test@email.com" + // } + private(set) static var validJWTStringWithEmailOnly = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ2YWx1ZSIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20ifQ.b-2oTvjpc_qHM5dU6akk_ESe3eWUZwL21pvTsCmW2gE" + + // Created with https://jwt.io/ with input: + // + // header: { + // "alg": "HS256", + // "typ": "JWT" + // } + // payload: { + // "name": "John Doe", + // "key": "value" + // } + private(set) static var validJWTStringWithNameOnly = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJrZXkiOiJ2YWx1ZSJ9.P7Se5_EMlFBg5q8PV4C2IQ1YojTTSgitCBX7FgmXZzs" + + // Created with https://jwt.io/ with input: + // + // header: { + // "alg": "HS256", + // "typ": "JWT" + // } + // payload: { + // "name": "John Doe", + // "key": "value", + // "email": "test@email.com" + // } + private(set) static var validJWTStringWithNameAndEmail = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJrZXkiOiJ2YWx1ZSIsImVtYWlsIjoidGVzdEBlbWFpbC5jb20ifQ.-xzg0r5mMnSZ8hE3hk7S93iCZHhOez1QFYdheSmDlx4" + + // For convenience, this exposes the email and name value used in the fixtures. + // This allows us to use raw strings in tests, rather than having to implement encoding the JWT from an arbitrary string. + private(set) static var emailFromValidJWTStringWithEmail = "test@email.com" + private(set) static var nameFromValidJWTStringWithEmail = "John Doe" +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/JWTokenTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/JWTokenTests.swift new file mode 100644 index 000000000000..c3ea0435cf74 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/JWTokenTests.swift @@ -0,0 +1,28 @@ +@testable import WordPressAuthenticator +import XCTest + +class JWTokenTests: XCTestCase { + + func testJWTokenDecodingFromInvalidStringFails() { + XCTAssertNil(JSONWebToken(encodedString: "invalid")) + } + + func testJWTokenDecodingWithoutHeaderFails() { + let inputWithoutHeader = JSONWebToken.validJWTString.split(separator: ".").dropFirst().joined(separator: ".") + XCTAssertNil(JSONWebToken(encodedString: inputWithoutHeader)) + } + + func testJWTokenDecodingFromValidString() throws { + let token = try XCTUnwrap(JSONWebToken(encodedString: JSONWebToken.validJWTString)) + + XCTAssertEqual( + token.header as? [String: String], + ["alg": "HS256", "typ": "JWT"] + ) + + XCTAssertEqual( + token.payload as? [String: String], + ["key": "value", "other_key": "other_value"] + ) + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/NewGoogleAuthenticatorTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/NewGoogleAuthenticatorTests.swift new file mode 100644 index 000000000000..eebeb0d8b9b8 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/NewGoogleAuthenticatorTests.swift @@ -0,0 +1,105 @@ +@testable import WordPressAuthenticator +import XCTest + +class NewGoogleAuthenticatorTests: XCTestCase { + + let fakeClientId = GoogleClientId(string: "a.b.c")! + + func testRequestingOAuthTokenThrowsIfCodeCannotBeExtractedFromURL() async throws { + // Notice the use of a stub that returns a successful value. + // This way, if we get an error, we can be more confident it's legit. + let authenticator = NewGoogleAuthenticator( + clientId: fakeClientId, + scheme: "scheme", + audience: "audience", + oautTokenGetter: GoogleOAuthTokenGettingStub(response: .fixture()) + ) + let url = URL(string: "https://test.com?without=code")! + + do { + _ = try await authenticator.requestOAuthToken( + url: url, + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + pkce: ProofKeyForCodeExchange() + ) + XCTFail("Expected an error to be thrown") + } catch { + let error = try XCTUnwrap(error as? OAuthError) + guard case .urlDidNotContainCodeParameter(let urlFromError) = error else { + return XCTFail("Received unexpected error \(error)") + } + XCTAssertEqual(urlFromError, url) + } + } + + func testRequestingOAuthTokenRethrowsTheErrorItRecives() async throws { + let authenticator = NewGoogleAuthenticator( + clientId: fakeClientId, + scheme: "scheme", + audience: "audience", + oautTokenGetter: GoogleOAuthTokenGettingStub(error: TestError(id: 1)) + ) + let url = URL(string: "https://test.com?code=a_code")! + + do { + _ = try await authenticator.requestOAuthToken( + url: url, + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + pkce: ProofKeyForCodeExchange() + ) + XCTFail("Expected an error to be thrown") + } catch { + let error = try XCTUnwrap(error as? TestError) + XCTAssertEqual(error.id, 1) + } + } + + func testRequestingOAuthTokenThrowsIfIdTokenMissingFromResponse() async throws { + let authenticator = NewGoogleAuthenticator( + clientId: fakeClientId, + scheme: "scheme", + audience: "audience", + oautTokenGetter: GoogleOAuthTokenGettingStub(response: .fixture(rawIDToken: .none)) + ) + let url = URL(string: "https://test.com?code=a_code")! + + do { + _ = try await authenticator.requestOAuthToken( + url: url, + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + pkce: ProofKeyForCodeExchange() + ) + XCTFail("Expected an error to be thrown") + } catch { + let error = try XCTUnwrap(error as? OAuthError) + guard case .tokenResponseDidNotIncludeIdToken = error else { + return XCTFail("Received unexpected error \(error)") + } + } + } + + func testRequestingOAuthTokenReturnsTokenIfSuccessful() async throws { + let authenticator = NewGoogleAuthenticator( + clientId: fakeClientId, + scheme: "scheme", + audience: "audience", + oautTokenGetter: GoogleOAuthTokenGettingStub(response: .fixture(rawIDToken: JSONWebToken.validJWTStringWithNameAndEmail)) + ) + let url = URL(string: "https://test.com?code=a_code")! + + do { + let response = try await authenticator.requestOAuthToken( + url: url, + clientId: GoogleClientId(string: "a.b.c")!, + audience: "audience", + pkce: ProofKeyForCodeExchange() + ) + XCTAssertEqual(response.email, JSONWebToken.emailFromValidJWTStringWithEmail) + } catch { + XCTFail("Expected value, got error '\(error)'") + } + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift new file mode 100644 index 000000000000..ded44ae4bb31 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthRequestBody+GoogleSignInTests.swift @@ -0,0 +1,22 @@ +@testable import WordPressAuthenticator +import XCTest + +class OAuthRequestBodyGoogleSignInTests: XCTestCase { + + func testGoogleSignInTokenRequestBody() throws { + let codeVerifier = ProofKeyForCodeExchange.CodeVerifier.fixture() + let pkce = ProofKeyForCodeExchange(codeVerifier: codeVerifier, method: .plain) + let body = OAuthTokenRequestBody.googleSignInRequestBody( + clientId: GoogleClientId(string: "com.app.123-abc")!, + audience: "audience", + authCode: "codeValue", + pkce: pkce + ) + + XCTAssertEqual(body.clientId, "com.app.123-abc") + XCTAssertEqual(body.clientSecret, "") + XCTAssertEqual(body.codeVerifier, codeVerifier) + XCTAssertEqual(body.grantType, "authorization_code") + XCTAssertEqual(body.redirectURI, "123-abc.app.com:/oauth2callback") + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenRequestBodyTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenRequestBodyTests.swift new file mode 100644 index 000000000000..917f5e75aa28 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenRequestBodyTests.swift @@ -0,0 +1,28 @@ +@testable import WordPressAuthenticator +import XCTest + +class OAuthTokenRequestBodyTests: XCTestCase { + + func testURLEncodedDataConversion() throws { + let codeVerifier = ProofKeyForCodeExchange.CodeVerifier.fixture() + let body = OAuthTokenRequestBody( + clientId: "clientId", + clientSecret: "clientSecret", + audience: "audience", + code: "codeValue", + codeVerifier: codeVerifier, + grantType: "grantType", + redirectURI: "redirectUri" + ) + + let data = try body.asURLEncodedData() + + let decodedData = try XCTUnwrap(String(data: data, encoding: .utf8)) + + XCTAssertTrue(decodedData.contains("client_id=clientId")) + XCTAssertTrue(decodedData.contains("client_secret=clientSecret")) + XCTAssertTrue(decodedData.contains("code_verifier=\(codeVerifier.rawValue)")) + XCTAssertTrue(decodedData.contains("grant_type=grantType")) + XCTAssertTrue(decodedData.contains("redirect_uri=redirectUri")) + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenResponseBody+Fixture.swift b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenResponseBody+Fixture.swift new file mode 100644 index 000000000000..22bda7321e41 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/OAuthTokenResponseBody+Fixture.swift @@ -0,0 +1,15 @@ +@testable import WordPressAuthenticator + +extension OAuthTokenResponseBody { + + static func fixture(rawIDToken: String? = JSONWebToken.validJWTString) -> Self { + OAuthTokenResponseBody( + accessToken: "access_token", + expiresIn: 1, + rawIDToken: rawIDToken, + refreshToken: .none, + scope: "s", + tokenType: "t" + ) + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/ProofKeyForCodeExchangeTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/ProofKeyForCodeExchangeTests.swift new file mode 100644 index 000000000000..708ea7a203ca --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/ProofKeyForCodeExchangeTests.swift @@ -0,0 +1,31 @@ +@testable import WordPressAuthenticator +import XCTest + +class ProofKeyForCodeExchangeTests: XCTestCase { + + func testCodeChallengeInPlainModeIsTheSameAsCodeVerifier() throws { + let codeVerifier = ProofKeyForCodeExchange.CodeVerifier.fixture() + + XCTAssertEqual( + ProofKeyForCodeExchange(codeVerifier: codeVerifier, method: .plain).codeChallenge, + codeVerifier.rawValue + ) + } + + func testCodeChallengeInS256ModeIsEncodedAsPerSpec() { + let codeVerifier = ProofKeyForCodeExchange.CodeVerifier(value: (0..<9).map { _ in "test-" }.joined())! + + XCTAssertEqual( + ProofKeyForCodeExchange(codeVerifier: codeVerifier, method: .s256).codeChallenge, + "lWvomVEGuL8FR3DY2DP_9E2q_imlqUHi-s1SPqRhO2c" + ) + } + + func testMethodURLQueryParameterValuePlain() { + XCTAssertEqual(ProofKeyForCodeExchange.Method.plain.urlQueryParameterValue, "plain") + } + + func testMethodURLQueryParameterValueS256() { + XCTAssertEqual(ProofKeyForCodeExchange.Method.s256.urlQueryParameterValue, "S256") + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/Result+ConvenienceInitTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/Result+ConvenienceInitTests.swift new file mode 100644 index 000000000000..7b9a86064533 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/Result+ConvenienceInitTests.swift @@ -0,0 +1,40 @@ +@testable import WordPressAuthenticator +import XCTest + +class ResultConvenienceInitTests: XCTestCase { + + func testResultWithOptionalInputs() throws { + // Syntax sugar to keep line length shorter. SUT = System Under Test + typealias SUT = Result + + let testError = NSError(domain: "test", code: 1, userInfo: .none) + + // When value is some and error is nil, returns the value + XCTAssertEqual( + try XCTUnwrap(SUT(value: 1, error: .none, inconsistentStateError: testError).get()), + 1 + ) + + // When value is some and error is some, returns the error + let someError = NSError(domain: "test", code: 2) + XCTAssertThrowsError( + try SUT(value: 1, error: someError, inconsistentStateError: testError).get() + ) { error in + XCTAssertEqual(error as NSError, someError) + } + + // When value is none and error is some, returns the error + XCTAssertThrowsError( + try SUT(value: .none, error: someError, inconsistentStateError: testError).get() + ) { error in + XCTAssertEqual(error as NSError, someError) + } + + // When both value and error are none, returns the given error for this inconsistent state + XCTAssertThrowsError( + try SUT(value: .none, error: .none, inconsistentStateError: testError).get() + ) { error in + XCTAssertEqual(error as NSError, testError) + } + } +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/URL+GoogleSignInTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/URL+GoogleSignInTests.swift new file mode 100644 index 000000000000..a112065b7f95 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/URL+GoogleSignInTests.swift @@ -0,0 +1,96 @@ +@testable import WordPressAuthenticator +import XCTest + +class URLGoogleSignInTests: XCTestCase { + + func testGoogleSignInAuthURL() throws { + let pkce = try ProofKeyForCodeExchange() + let url = try URL.googleSignInAuthURL( + clientId: GoogleClientId(string: "123-abc245def.apps.googleusercontent.com")!, + pkce: pkce + ) + + assert(url, matchesBaseURL: "https://accounts.google.com/o/oauth2/v2/auth") + assertQueryItems( + for: url, + includeItemNamed: "client_id", + withValue: "123-abc245def.apps.googleusercontent.com" + ) + assertQueryItems( + for: url, + includeItemNamed: "code_challenge", + withValue: pkce.codeChallenge + ) + assertQueryItems( + for: url, + includeItemNamed: "code_challenge_method", + withValue: pkce.method.urlQueryParameterValue + ) + assertQueryItems( + for: url, + includeItemNamed: "redirect_uri", + withValue: "com.googleusercontent.apps.123-abc245def:/oauth2callback" + ) + assertQueryItems( + for: url, + includeItemNamed: "scope", + withValue: "profile email" + ) + assertQueryItems(for: url, includeItemNamed: "response_type", withValue: "code") + } +} + +func assert( + _ actual: URL, + matchesBaseURL baseURLString: String, + file: StaticString = #file, + line: UInt = #line +) { + guard var components = URLComponents(url: actual, resolvingAgainstBaseURL: false) else { + return XCTFail( + "Could not created `URLComponents` from given `URL` \(actual).", + file: file, + line: line + ) + } + + components.query = .none + + guard let baseURL = components.url else { + return XCTFail( + "Could not extract `URL` from `URLComponents` created from \(actual).", + file: file, + line: line + ) + } + + XCTAssertEqual(baseURL.absoluteString, baseURLString, file: file, line: line) +} + +func assertQueryItems( + for url: URL, + includeItemNamed name: String, + withValue value: String?, + file: StaticString = #file, + line: UInt = #line +) { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return XCTFail( + "Could not created `URLComponents` from given `URL` \(url).", + file: file, + line: line + ) + } + + guard let queryItems = components.queryItems else { + XCTFail("URL \(url) has no query items", file: file, line: line) + return + } + + XCTAssertTrue( + queryItems.contains(where: { $0.name == name && $0.value == value }), + "Could not find query item with name '\(name)' and value '\(value ?? "nil")'. Query items found: \(queryItems.map { "'name: \($0.name), value: \($0.value ?? "nil")'" }.joined(separator: ", "))", + file: file, + line: line + ) +} diff --git a/WordPressAuthenticator/Tests/GoogleSignIn/URLRequest+GoogleSignInTests.swift b/WordPressAuthenticator/Tests/GoogleSignIn/URLRequest+GoogleSignInTests.swift new file mode 100644 index 000000000000..384bb5556cf7 --- /dev/null +++ b/WordPressAuthenticator/Tests/GoogleSignIn/URLRequest+GoogleSignInTests.swift @@ -0,0 +1,33 @@ +@testable import WordPressAuthenticator +import XCTest + +class URLRequestOAuthTokenRequestTests: XCTestCase { + + let testBody = OAuthTokenRequestBody( + clientId: "a", + clientSecret: "b", + audience: "audience", + code: "c", + codeVerifier: ProofKeyForCodeExchange.CodeVerifier.fixture(), + grantType: "e", + redirectURI: "f" + ) + + func testURL() throws { + let request = try URLRequest.googleSignInTokenRequest(body: testBody) + XCTAssertEqual(request.url, URL(string: "https://oauth2.googleapis.com/token")!) + } + + func testMethodPost() throws { + let request = try URLRequest.googleSignInTokenRequest(body: testBody) + XCTAssertEqual(request.httpMethod, "POST") + } + + func testContentTypeFormURLEncoded() throws { + let request = try URLRequest.googleSignInTokenRequest(body: testBody) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Content-Type"), + "application/x-www-form-urlencoded; charset=UTF-8" + ) + } +} diff --git a/WordPressAuthenticator/Tests/Info.plist b/WordPressAuthenticator/Tests/Info.plist new file mode 100644 index 000000000000..6c40a6cd0c4a --- /dev/null +++ b/WordPressAuthenticator/Tests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/WordPressAuthenticator/Tests/Logging/LoggingTests.m b/WordPressAuthenticator/Tests/Logging/LoggingTests.m new file mode 100644 index 000000000000..1112269abec0 --- /dev/null +++ b/WordPressAuthenticator/Tests/Logging/LoggingTests.m @@ -0,0 +1,78 @@ +#import + +@import WordPressAuthenticator; + +@interface CaptureLogs : NSObject + +@property (nonatomic, strong) NSMutableArray *infoLogs; +@property (nonatomic, strong) NSMutableArray *errorLogs; + +@end + +// We are leaving some protocol methods intentionally unimplemented to then test that calling them +// will not cause a crash. +// +// See https://github.com/wordpress-mobile/WordPressAuthenticator-iOS/pull/720#issuecomment-1374952619 +#pragma clang diagnostic ignored "-Wprotocol" +@implementation CaptureLogs + +- (instancetype)init +{ + if ((self = [super init])) { + self.infoLogs = [NSMutableArray new]; + self.errorLogs = [NSMutableArray new]; + } + return self; +} + +- (void)logInfo:(NSString *)str +{ + [self.infoLogs addObject:str]; +} + +- (void)logError:(NSString *)str +{ + [self.errorLogs addObject:str]; +} + +@end +#pragma clang diagnostic pop + +@interface ObjCLoggingTest : XCTestCase + +@property (nonatomic, strong) CaptureLogs *logger; + +@end + +@implementation ObjCLoggingTest + +- (void)setUp +{ + self.logger = [CaptureLogs new]; + WPAuthenticatorSetLoggingDelegate(self.logger); +} + +- (void)testLogging +{ + WPAuthenticatorLogInfo(@"This is an info log"); + WPAuthenticatorLogInfo(@"This is an info log %@", @"with an argument"); + XCTAssertEqualObjects(self.logger.infoLogs, (@[@"This is an info log", @"This is an info log with an argument"])); + + WPAuthenticatorLogError(@"This is an error log"); + WPAuthenticatorLogError(@"This is an error log %@", @"with an argument"); + XCTAssertEqualObjects(self.logger.errorLogs, (@[@"This is an error log", @"This is an error log with an argument"])); +} + +- (void)testUnimplementedLoggingMethod +{ + XCTAssertNoThrow(WPAuthenticatorLogVerbose(@"verbose logging is not implemented")); +} + +- (void)testNoLogging +{ + WPAuthenticatorSetLoggingDelegate(nil); + XCTAssertNoThrow(WPAuthenticatorLogInfo(@"this log should not be printed")); + XCTAssertEqual(self.logger.infoLogs.count, 0); +} + +@end diff --git a/WordPressAuthenticator/Tests/Logging/LoggingTests.swift b/WordPressAuthenticator/Tests/Logging/LoggingTests.swift new file mode 100644 index 000000000000..29fb1b3acb89 --- /dev/null +++ b/WordPressAuthenticator/Tests/Logging/LoggingTests.swift @@ -0,0 +1,70 @@ +import XCTest + +@testable import WordPressAuthenticator + +private class CaptureLogs: NSObject, WordPressLoggingDelegate { + var verboseLogs = [String]() + var debugLogs = [String]() + var infoLogs = [String]() + var warningLogs = [String]() + var errorLogs = [String]() + + func logError(_ str: String) { + errorLogs.append(str) + } + + func logWarning(_ str: String) { + warningLogs.append(str) + } + + func logInfo(_ str: String) { + infoLogs.append(str) + } + + func logDebug(_ str: String) { + debugLogs.append(str) + } + + func logVerbose(_ str: String) { + verboseLogs.append(str) + } + +} + +class LoggingTest: XCTestCase { + + private let logger = CaptureLogs() + + override func setUp() { + WPAuthenticatorSetLoggingDelegate(logger) + } + + func testLogging() { + WPAuthenticatorLogVerbose("This is a verbose log") + WPAuthenticatorLogVerbose("This is a verbose log %@", "with an argument") + XCTAssertEqual(self.logger.verboseLogs, ["This is a verbose log", "This is a verbose log with an argument"]) + + WPAuthenticatorLogDebug("This is a debug log") + WPAuthenticatorLogDebug("This is a debug log %@", "with an argument") + XCTAssertEqual(self.logger.debugLogs, ["This is a debug log", "This is a debug log with an argument"]) + + WPAuthenticatorLogInfo("This is an info log") + WPAuthenticatorLogInfo("This is an info log %@", "with an argument") + XCTAssertEqual(self.logger.infoLogs, ["This is an info log", "This is an info log with an argument"]) + + WPAuthenticatorLogWarning("This is a warning log") + WPAuthenticatorLogWarning("This is a warning log %@", "with an argument") + XCTAssertEqual(self.logger.warningLogs, ["This is a warning log", "This is a warning log with an argument"]) + + WPAuthenticatorLogError("This is an error log") + WPAuthenticatorLogError("This is an error log %@", "with an argument") + XCTAssertEqual(self.logger.errorLogs, ["This is an error log", "This is an error log with an argument"]) + } + + func testNoLogging() { + WPAuthenticatorSetLoggingDelegate(nil) + XCTAssertNoThrow(WPAuthenticatorLogInfo("this log should not be printed")) + XCTAssertEqual(self.logger.infoLogs.count, 0) + } + +} diff --git a/WordPressAuthenticator/Tests/MemoryManagementTests.swift b/WordPressAuthenticator/Tests/MemoryManagementTests.swift new file mode 100644 index 000000000000..316df137c78b --- /dev/null +++ b/WordPressAuthenticator/Tests/MemoryManagementTests.swift @@ -0,0 +1,60 @@ +@testable import WordPressAuthenticator +import XCTest + +final class MemoryManagementTests: XCTestCase { + override func setUp() { + super.setUp() + + WordPressAuthenticator.initialize( + configuration: WordpressAuthenticatorProvider.wordPressAuthenticatorConfiguration(), + style: WordpressAuthenticatorProvider.wordPressAuthenticatorStyle(.random), + unifiedStyle: WordpressAuthenticatorProvider.wordPressAuthenticatorUnifiedStyle(.random) + ) + } + + func testViewControllersDeallocatedAfterDismissing() { + let viewControllers: [UIViewController] = [ + Storyboard.login.instance.instantiateInitialViewController()!, + LoginPrologueLoginMethodViewController.instantiate(from: .login)!, + LoginPrologueSignupMethodViewController.instantiate(from: .login)!, + Login2FAViewController.instantiate(from: .login)!, + LoginEmailViewController.instantiate(from: .login)!, + LoginSelfHostedViewController.instantiate(from: .login)!, + LoginSiteAddressViewController.instantiate(from: .login)!, + LoginUsernamePasswordViewController.instantiate(from: .login)!, + LoginWPComViewController.instantiate(from: .login)!, + SignupEmailViewController.instantiate(from: .signup)!, + SignupGoogleViewController.instantiate(from: .signup)!, + GetStartedViewController.instantiate(from: .getStarted)!, + VerifyEmailViewController.instantiate(from: .verifyEmail)!, + PasswordViewController.instantiate(from: .password)!, + TwoFAViewController.instantiate(from: .twoFA)!, + GoogleAuthViewController.instantiate(from: .googleAuth)!, + SiteAddressViewController.instantiate(from: .siteAddress)!, + SiteCredentialsViewController.instantiate(from: .siteAddress)! + ] + + for viewController in viewControllers { + viewController.loadViewIfNeeded() + } + + verifyObjectsDeallocatedAfterTeardown(viewControllers) + } + + // MARK: - Helpers + + private func verifyObjectsDeallocatedAfterTeardown(_ objects: [AnyObject]) { + /// Create the array of weak objects so we could assert them in the teardown block + let weakObjects: [() -> AnyObject?] = objects.map { object in { [weak object] in + return object + } + } + + /// All the weak items should be deallocated in the teardown block unless there's a retain cycle holding them + addTeardownBlock { + for object in weakObjects { + XCTAssertNil(object(), "\(object()!.self) is not deallocated after teardown") + } + } + } +} diff --git a/WordPressAuthenticator/Tests/Mocks/MockNavigationController.swift b/WordPressAuthenticator/Tests/Mocks/MockNavigationController.swift new file mode 100644 index 000000000000..a0e5cadfd329 --- /dev/null +++ b/WordPressAuthenticator/Tests/Mocks/MockNavigationController.swift @@ -0,0 +1,10 @@ +import UIKit + +final class MockNavigationController: UINavigationController { + var pushedViewController: UIViewController? + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + pushedViewController = viewController + super.pushViewController(viewController, animated: true) + } +} diff --git a/WordPressAuthenticator/Tests/Mocks/ModalViewControllerPresentingSpy.swift b/WordPressAuthenticator/Tests/Mocks/ModalViewControllerPresentingSpy.swift new file mode 100644 index 000000000000..01caa7da932b --- /dev/null +++ b/WordPressAuthenticator/Tests/Mocks/ModalViewControllerPresentingSpy.swift @@ -0,0 +1,8 @@ +@testable import WordPressAuthenticator + +class ModalViewControllerPresentingSpy: UIViewController { + internal var presentedVC: UIViewController? = .none + override func present(_ viewControllerToPresent: UIViewController, animated: Bool, completion: (() -> Void)?) { + presentedVC = viewControllerToPresent + } +} diff --git a/WordPressAuthenticator/Tests/Mocks/WordPressAuthenticatorDelegateSpy.swift b/WordPressAuthenticator/Tests/Mocks/WordPressAuthenticatorDelegateSpy.swift new file mode 100644 index 000000000000..ff9ab9d51709 --- /dev/null +++ b/WordPressAuthenticator/Tests/Mocks/WordPressAuthenticatorDelegateSpy.swift @@ -0,0 +1,82 @@ +@testable import WordPressAuthenticator +import WordPressKit + +class WordPressAuthenticatorDelegateSpy: WordPressAuthenticatorDelegate { + var dismissActionEnabled: Bool = true + var supportActionEnabled: Bool = true + var wpcomTermsOfServiceEnabled: Bool = true + var showSupportNotificationIndicator: Bool = true + var supportEnabled: Bool = true + var allowWPComLogin: Bool = true + var shouldHandleError: Bool = false + + private(set) var presentSignupEpilogueCalled = false + private(set) var socialUser: SocialUser? + + func createdWordPressComAccount(username: String, authToken: String) { + // no-op + } + + func userAuthenticatedWithAppleUserID(_ appleUserID: String) { + // no-op + } + + func presentSupportRequest(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag) { + // no-op + } + + func shouldPresentUsernamePasswordController(for siteInfo: WordPressComSiteInfo?, onCompletion: @escaping (WordPressAuthenticatorResult) -> Void) { + // no-op + } + + func presentLoginEpilogue(in navigationController: UINavigationController, for credentials: AuthenticatorCredentials, source: SignInSource?, onDismiss: @escaping () -> Void) { + // no-op + } + + func presentSignupEpilogue( + in navigationController: UINavigationController, + for credentials: AuthenticatorCredentials, + socialUser: SocialUser? + ) { + presentSignupEpilogueCalled = true + self.socialUser = socialUser + } + + func presentSupport(from sourceViewController: UIViewController, sourceTag: WordPressSupportSourceTag, lastStep: AuthenticatorAnalyticsTracker.Step, lastFlow: AuthenticatorAnalyticsTracker.Flow) { + // no-op + } + + func shouldPresentLoginEpilogue(isJetpackLogin: Bool) -> Bool { + true + } + + func shouldHandleError(_ error: Error) -> Bool { + shouldHandleError + } + + func handleError(_ error: Error, onCompletion: @escaping (UIViewController) -> Void) { + if shouldHandleError { + onCompletion(UIViewController()) + } + } + + func shouldPresentSignupEpilogue() -> Bool { + true + } + + func sync(credentials: AuthenticatorCredentials, onCompletion: @escaping () -> Void) { + // no-op + } + + func track(event: WPAnalyticsStat) { + // no-op + } + + func track(event: WPAnalyticsStat, properties: [AnyHashable: Any]) { + // no-op + } + + func track(event: WPAnalyticsStat, error: Error) { + // no-op + } +} diff --git a/WordPressAuthenticator/Tests/Mocks/WordpressAuthenticatorProvider.swift b/WordPressAuthenticator/Tests/Mocks/WordpressAuthenticatorProvider.swift new file mode 100644 index 000000000000..08334d105f50 --- /dev/null +++ b/WordPressAuthenticator/Tests/Mocks/WordpressAuthenticatorProvider.swift @@ -0,0 +1,111 @@ +@testable import WordPressAuthenticator + +@objc +public class WordpressAuthenticatorProvider: NSObject { + static func wordPressAuthenticatorConfiguration() -> WordPressAuthenticatorConfiguration { + return WordPressAuthenticatorConfiguration(wpcomClientId: "23456", + wpcomSecret: "arfv35dj57l3g2323", + wpcomScheme: "https", + wpcomTermsOfServiceURL: URL(string: "https://wordpress.com/tos/")!, + googleLoginClientId: "", + googleLoginServerClientId: "", + googleLoginScheme: "com.googleuserconsent.apps", + userAgent: "") + } + + static func wordPressAuthenticatorStyle(_ style: AuthenticatorStyleType) -> WordPressAuthenticatorStyle { + var wpAuthStyle: WordPressAuthenticatorStyle! + + switch style { + case .random: + wpAuthStyle = WordPressAuthenticatorStyle( + primaryNormalBackgroundColor: UIColor.random(), + primaryNormalBorderColor: UIColor.random(), + primaryHighlightBackgroundColor: UIColor.random(), + primaryHighlightBorderColor: UIColor.random(), + secondaryNormalBackgroundColor: UIColor.random(), + secondaryNormalBorderColor: UIColor.random(), + secondaryHighlightBackgroundColor: UIColor.random(), + secondaryHighlightBorderColor: UIColor.random(), + disabledBackgroundColor: UIColor.random(), + disabledBorderColor: UIColor.random(), + primaryTitleColor: UIColor.random(), + secondaryTitleColor: UIColor.random(), + disabledTitleColor: UIColor.random(), + disabledButtonActivityIndicatorColor: UIColor.random(), + textButtonColor: UIColor.random(), + textButtonHighlightColor: UIColor.random(), + instructionColor: UIColor.random(), + subheadlineColor: UIColor.random(), + placeholderColor: UIColor.random(), + viewControllerBackgroundColor: UIColor.random(), + textFieldBackgroundColor: UIColor.random(), + navBarImage: UIImage(color: UIColor.random()), + navBarBadgeColor: UIColor.random(), + navBarBackgroundColor: UIColor.random() + ) + return wpAuthStyle + } + } + + static func wordPressAuthenticatorUnifiedStyle(_ style: AuthenticatorStyleType) -> WordPressAuthenticatorUnifiedStyle { + var wpUnifiedAuthStyle: WordPressAuthenticatorUnifiedStyle! + + switch style { + case .random: + wpUnifiedAuthStyle = WordPressAuthenticatorUnifiedStyle( + borderColor: UIColor.random(), + errorColor: UIColor.random(), + textColor: UIColor.random(), + textSubtleColor: UIColor.random(), + textButtonColor: UIColor.random(), + textButtonHighlightColor: UIColor.random(), + viewControllerBackgroundColor: UIColor.random(), + navBarBackgroundColor: UIColor.random(), + navButtonTextColor: UIColor.random(), + navTitleTextColor: UIColor.random() + ) + return wpUnifiedAuthStyle + } + } + + static func getWordpressAuthenticator() -> WordPressAuthenticator { + return WordPressAuthenticator( + configuration: wordPressAuthenticatorConfiguration(), + style: wordPressAuthenticatorStyle(.random), + unifiedStyle: wordPressAuthenticatorUnifiedStyle(.random), + displayImages: WordPressAuthenticatorDisplayImages.defaultImages, + displayStrings: WordPressAuthenticatorDisplayStrings.defaultStrings) + } + + @objc + static func initializeWordPressAuthenticator() { + WordPressAuthenticator.initialize( + configuration: wordPressAuthenticatorConfiguration(), + style: wordPressAuthenticatorStyle(.random), + unifiedStyle: wordPressAuthenticatorUnifiedStyle(.random), + displayImages: WordPressAuthenticatorDisplayImages.defaultImages, + displayStrings: WordPressAuthenticatorDisplayStrings.defaultStrings) + } +} + +enum AuthenticatorStyleType { + case random +} + +extension CGFloat { + static func random() -> CGFloat { + return CGFloat(arc4random()) / CGFloat(UInt32.max) + } +} + +extension UIColor { + static func random() -> UIColor { + return UIColor( + red: .random(), + green: .random(), + blue: .random(), + alpha: 1.0 + ) + } +} diff --git a/WordPressAuthenticator/Tests/Model/LoginFieldsTests.swift b/WordPressAuthenticator/Tests/Model/LoginFieldsTests.swift new file mode 100644 index 000000000000..395fed091acc --- /dev/null +++ b/WordPressAuthenticator/Tests/Model/LoginFieldsTests.swift @@ -0,0 +1,29 @@ +@testable import WordPressAuthenticator +import XCTest + +class LoginFieldsTests: XCTestCase { + + func testSignInWithAppleParametersNilWhenNoSocialUser() { + XCTAssertNil(LoginFields().parametersForSignInWithApple) + } + + func testSignInWithAppleParametersNilWhenSocialUserNotApple() { + let fields = LoginFields() + fields.meta = LoginFieldsMeta( + socialUser: SocialUser(email: "email", fullName: "name", service: .google) + ) + + XCTAssertNil(fields.parametersForSignInWithApple) + } + + func testSignInWithAppleParametersHasEmailAndNameWhenSocialUserIsApple() throws { + let fields = LoginFields() + fields.meta = LoginFieldsMeta( + socialUser: SocialUser(email: "email", fullName: "name", service: .apple) + ) + + let parameters = try XCTUnwrap(fields.parametersForSignInWithApple) + XCTAssertEqual(parameters["user_email"] as? String, "email") + XCTAssertEqual(parameters["user_name"] as? String, "name") + } +} diff --git a/WordPressAuthenticator/Tests/Model/LoginFieldsValidationTests.swift b/WordPressAuthenticator/Tests/Model/LoginFieldsValidationTests.swift new file mode 100644 index 000000000000..e3d430aab7f9 --- /dev/null +++ b/WordPressAuthenticator/Tests/Model/LoginFieldsValidationTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import WordPressAuthenticator + +// MARK: - LoginFields Validation Tests +// +class LoginFieldsValidationTests: XCTestCase { + + func testValidateFieldsPopulatedForSignin() { + let loginFields = LoginFields() + loginFields.meta.userIsDotCom = true + + XCTAssertFalse(loginFields.validateFieldsPopulatedForSignin(), "Empty fields should not validate.") + + loginFields.username = "user" + XCTAssertFalse(loginFields.validateFieldsPopulatedForSignin(), "Should not validate with just a username") + + loginFields.password = "password" + XCTAssert(loginFields.validateFieldsPopulatedForSignin(), "should validate wpcom with username and password.") + + loginFields.meta.userIsDotCom = false + XCTAssertFalse(loginFields.validateFieldsPopulatedForSignin(), "should not validate self-hosted with just username and password.") + + loginFields.siteAddress = "example.com" + XCTAssert(loginFields.validateFieldsPopulatedForSignin(), "should validate self-hosted with username, password, and site.") + } + + func testValidateSiteForSignin() { + let loginFields = LoginFields() + + loginFields.siteAddress = "" + XCTAssertFalse(loginFields.validateSiteForSignin(), "Empty site should not validate.") + + loginFields.siteAddress = "hostname" + XCTAssertTrue(loginFields.validateSiteForSignin(), "Hostnames should validate.") + + loginFields.siteAddress = "http://hostname" + XCTAssert(loginFields.validateSiteForSignin(), "Since we want to validate simple mistakes, to use a hostname you'll need an http:// or https:// prefix.") + + loginFields.siteAddress = "https://hostname" + XCTAssert(loginFields.validateSiteForSignin(), "Since we want to validate simple mistakes, to use a hostname you'll need an http:// or https:// prefix.") + + } +} diff --git a/WordPressAuthenticator/Tests/Model/WordPressComSiteInfoTests.swift b/WordPressAuthenticator/Tests/Model/WordPressComSiteInfoTests.swift new file mode 100644 index 000000000000..72f955f689ca --- /dev/null +++ b/WordPressAuthenticator/Tests/Model/WordPressComSiteInfoTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import WordPressAuthenticator + +final class WordPressComSiteInfoTests: XCTestCase { + private var subject: WordPressComSiteInfo! + + override func setUp() { + subject = WordPressComSiteInfo(remote: mock()) + super.setUp() + } + + override func tearDown() { + super.tearDown() + subject = nil + } + + func testJetpackActiveMatchesExpectation() { + XCTAssertTrue(subject.isJetpackActive) + } + + func testHasJetpackMatchesExpectation() { + XCTAssertTrue(subject.hasJetpack) + } + + func testJetpackConnectedMatchesExpectation() { + XCTAssertTrue(subject.isJetpackConnected) + } + + func testWPComMatchesExpectation() { + XCTAssertFalse(subject.isWPCom) + } + + func testWPMatchesExpectation() { + XCTAssertTrue(subject.isWP) + } +} + +private extension WordPressComSiteInfoTests { + func mock() -> [AnyHashable: Any] { + return [ + "isJetpackActive": true, + "jetpackVersion": false, + "isWordPressDotCom": false, + "urlAfterRedirects": "https://somewhere.com", + "hasJetpack": true, + "isWordPress": true, + "isJetpackConnected": true + ] as [AnyHashable: Any] + } +} diff --git a/WordPressAuthenticator/Tests/Navigation/NavigateBackTests.swift b/WordPressAuthenticator/Tests/Navigation/NavigateBackTests.swift new file mode 100644 index 000000000000..ea632bafa3dd --- /dev/null +++ b/WordPressAuthenticator/Tests/Navigation/NavigateBackTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import WordPressAuthenticator + +final class NavigateBackTests: XCTestCase { + + func testNavigationCommandNavigatesToExpectedDestination() { + let origin = UIViewController() + let navigationController = MockNavigationController(rootViewController: origin) + navigationController.pushViewController(origin, animated: false) + + let command = NavigateBack() + command.execute(from: origin) + + let navigationStackCount = navigationController.viewControllers.count + + XCTAssertEqual(navigationStackCount, 1) + } +} diff --git a/WordPressAuthenticator/Tests/Navigation/NavigationToEnterAccountTests.swift b/WordPressAuthenticator/Tests/Navigation/NavigationToEnterAccountTests.swift new file mode 100644 index 000000000000..eac10b685094 --- /dev/null +++ b/WordPressAuthenticator/Tests/Navigation/NavigationToEnterAccountTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import WordPressAuthenticator + +final class NavigationToAccountTests: XCTestCase { + func testNavigationCommandNavigatesToExpectedDestination() { + let origin = UIViewController() + let navigationController = MockNavigationController(rootViewController: origin) + + let command = NavigateToEnterAccount(signInSource: .wpCom) + command.execute(from: origin) + + let pushedViewController = navigationController.pushedViewController + + XCTAssertNotNil(pushedViewController) + XCTAssertTrue(pushedViewController is GetStartedViewController) + } +} diff --git a/WordPressAuthenticator/Tests/Navigation/NavigationToEnterSiteTests.swift b/WordPressAuthenticator/Tests/Navigation/NavigationToEnterSiteTests.swift new file mode 100644 index 000000000000..8c8452548545 --- /dev/null +++ b/WordPressAuthenticator/Tests/Navigation/NavigationToEnterSiteTests.swift @@ -0,0 +1,17 @@ +import XCTest +@testable import WordPressAuthenticator + +final class NavigationToEnterSiteTests: XCTestCase { + func testNavigationCommandNavigatesToExpectedDestination() { + let origin = UIViewController() + let navigationController = MockNavigationController(rootViewController: origin) + + let command = NavigateToEnterSite() + command.execute(from: origin) + + let pushedViewController = navigationController.pushedViewController + + XCTAssertNotNil(pushedViewController) + XCTAssertTrue(pushedViewController is SiteAddressViewController) + } +} diff --git a/WordPressAuthenticator/Tests/Navigation/NavigationToRootTests.swift b/WordPressAuthenticator/Tests/Navigation/NavigationToRootTests.swift new file mode 100644 index 000000000000..d5c082b5cd94 --- /dev/null +++ b/WordPressAuthenticator/Tests/Navigation/NavigationToRootTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import WordPressAuthenticator + +final class NavigationToRootTests: XCTestCase { + + func testNavigationCommandNavigatesToExpectedDestination() { + let origin = UIViewController() + let navigationController = MockNavigationController(rootViewController: origin) + navigationController.pushViewController(origin, animated: false) + + let command = NavigateToRoot() + command.execute(from: origin) + + let navigationStackCount = navigationController.viewControllers.count + + XCTAssertEqual(navigationStackCount, 1) + } +} diff --git a/WordPressAuthenticator/Tests/Services/LoginFacadeTests.m b/WordPressAuthenticator/Tests/Services/LoginFacadeTests.m new file mode 100644 index 000000000000..942f1906e273 --- /dev/null +++ b/WordPressAuthenticator/Tests/Services/LoginFacadeTests.m @@ -0,0 +1,273 @@ +#import +#define EXP_SHORTHAND +#import +#import +#import "LoginFacade.h" +#import "WordPressXMLRPCAPIFacade.h" +#import "WPAuthenticator-Swift.h" +#import "WordPressAuthenticatorTests-Swift.h" +@import WordPressKit; + + +SpecBegin(LoginFacade) + +__block LoginFacade *loginFacade; +__block id mockOAuthFacade; +__block id mockXMLRPCAPIFacade; +__block id mockLoginFacade; +__block id mockLoginFacadeDelegate; +__block LoginFields *loginFields; + +beforeAll(^{ + [WordpressAuthenticatorProvider initializeWordPressAuthenticator]; +}); + +beforeEach(^{ + mockOAuthFacade = [OCMockObject niceMockForProtocol:@protocol(WordPressComOAuthClientFacadeProtocol)]; + mockXMLRPCAPIFacade = [OCMockObject niceMockForProtocol:@protocol(WordPressXMLRPCAPIFacade)]; + mockLoginFacadeDelegate = [OCMockObject niceMockForProtocol:@protocol(LoginFacadeDelegate)]; + + loginFacade = [LoginFacade new]; + loginFacade.wordpressComOAuthClientFacade = mockOAuthFacade; + loginFacade.wordpressXMLRPCAPIFacade = mockXMLRPCAPIFacade; + loginFacade.delegate = mockLoginFacadeDelegate; + + mockLoginFacade = OCMPartialMock(loginFacade); + OCMStub([[mockLoginFacade ignoringNonObjectArgs] track:0]); + OCMStub([[mockLoginFacade ignoringNonObjectArgs] track:0 error:[OCMArg any]]); + + loginFields = [LoginFields new]; + loginFields.username = @"username"; + loginFields.password = @"password"; + loginFields.siteAddress = @"www.mysite.com"; + loginFields.multifactorCode = @"123456"; +}); + +describe(@"signInWithLoginFields", ^{ + + context(@"for a .com user", ^{ + + beforeEach(^{ + loginFields.userIsDotCom = YES; + }); + + it(@"should display a message about 'Connecting to WordPress.com'", ^{ + [[mockLoginFacadeDelegate expect] displayLoginMessage:NSLocalizedString(@"Connecting to WordPress.com", nil)]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + it(@"should authenticate the user's credentials", ^{ + [[mockOAuthFacade expect] authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockOAuthFacade verify]; + }); + + it(@"should call LoginFacadeDelegate's finishedLoginWithUsername:authToken:shouldDisplayMultifactor: when authentication was successful", ^{ + // Intercept success callback and execute it when appropriate + NSString *authToken = @"auth-token"; + [OCMStub([mockOAuthFacade authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained successStub)(NSString *); + [invocation getArgument:&successStub atIndex:5]; + + successStub(authToken); + }]; + [[mockLoginFacadeDelegate expect] finishedLoginWithAuthToken:authToken requiredMultifactorCode:loginFields.requiredMultifactor]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + it(@"should call LoginServceDelegate's needsMultifactorCode when authentication requires it", ^{ + // Intercept success callback and execute it when appropriate + [OCMStub([mockOAuthFacade authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained needsMultifactorStub)(NSInteger, SocialLogin2FANonceInfo *); + [invocation getArgument:&needsMultifactorStub atIndex:6]; + + needsMultifactorStub(0, nil); + }]; + [[mockLoginFacadeDelegate expect] needsMultifactorCode]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + it(@"should call LoginServceDelegate's needsMultifactorCode:userID:nonceInfo when authentication requires it", ^{ + // Expected parameters + NSInteger userID = 1234; + SocialLogin2FANonceInfo * info = [SocialLogin2FANonceInfo new]; + + // Intercept success callback and execute it when appropriate + [OCMStub([mockOAuthFacade authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained needsMultifactorStub)(NSInteger, SocialLogin2FANonceInfo *); + [invocation getArgument:&needsMultifactorStub atIndex:6]; + + needsMultifactorStub(userID, info); + }]; + [[mockLoginFacadeDelegate expect] needsMultifactorCodeForUserID:userID andNonceInfo:info]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + it(@"should call LoginFacadeDelegate's displayRemoteError when there has been an error", ^{ + NSError *error = [NSError errorWithDomain:@"org.wordpress" code:-1 userInfo:@{ NSLocalizedDescriptionKey : @"Error" }]; + // Intercept success callback and execute it when appropriate + [OCMStub([mockOAuthFacade authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained failureStub)(NSError *); + [invocation getArgument:&failureStub atIndex:7]; + + failureStub(error); + }]; + [[mockLoginFacadeDelegate expect] displayRemoteError:error]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + }); + + context(@"for a self hosted user", ^{ + + beforeEach(^{ + loginFields.userIsDotCom = NO; + }); + + it(@"should display a message about 'Authenticating'", ^{ + [[mockLoginFacadeDelegate expect] displayLoginMessage:NSLocalizedString(@"Authenticating", nil)]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + context(@"the guessing of the xmlrpc url for the site", ^{ + + it(@"should occur", ^{ + [[mockXMLRPCAPIFacade expect] guessXMLRPCURLForSite:loginFields.siteAddress success:OCMOCK_ANY failure:OCMOCK_ANY]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockXMLRPCAPIFacade verify]; + }); + + context(@"when successful", ^{ + + __block NSURL *xmlrpc; + + beforeEach(^{ + xmlrpc = [NSURL URLWithString:@"http://www.selfhosted.com/xmlrpc.php"]; + // Intercept success callback and execute it when appropriate + [OCMStub([mockXMLRPCAPIFacade guessXMLRPCURLForSite:loginFields.siteAddress success:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained successStub)(NSURL *); + [invocation getArgument:&successStub atIndex:3]; + + successStub(xmlrpc); + }]; + }); + + it(@"should result in attempting to retrieve the blog's options", ^{ + [[mockXMLRPCAPIFacade expect] getBlogOptionsWithEndpoint:xmlrpc username:loginFields.username password:loginFields.password success:OCMOCK_ANY failure:OCMOCK_ANY]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockXMLRPCAPIFacade verify]; + }); + + context(@"successfully retrieving the blog's options", ^{ + + __block NSMutableDictionary *options; + + beforeEach(^{ + options = [NSMutableDictionary dictionaryWithDictionary:@{@"software_version":@{@"value":@"4.2"}}]; + + // Intercept success callback and execute it when appropriate + [OCMStub([mockXMLRPCAPIFacade getBlogOptionsWithEndpoint:xmlrpc username:loginFields.username password:loginFields.password success:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained successStub)(NSDictionary *); + [invocation getArgument:&successStub atIndex:5]; + + successStub(options); + }]; + }); + + it(@"should indicate to the LoginFacadeDelegate it's finished logging in with those credentials", ^{ + [[mockLoginFacadeDelegate expect] finishedLoginWithUsername:loginFields.username password:loginFields.password xmlrpc:[xmlrpc absoluteString] options:options]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + + it(@"should attempt to authenticate for WordPress.com when it detects the site is a WordPress.com site", ^{ + options[@"wordpress.com"] = @YES; + [[mockOAuthFacade expect] authenticateWithUsername:loginFields.username password:loginFields.password multifactorCode:loginFields.multifactorCode success:OCMOCK_ANY needsMultifactor:OCMOCK_ANY failure:OCMOCK_ANY]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockOAuthFacade verify]; + }); + }); + + context(@"failure of retrieving the blog's options", ^{ + + __block NSError *error; + + beforeEach(^{ + error = [NSError errorWithDomain:@"org.wordpress" code:-1 userInfo:@{ NSLocalizedDescriptionKey : @"Error" }]; + + // Intercept failure callback and execute it when appropriate + [OCMStub([mockXMLRPCAPIFacade getBlogOptionsWithEndpoint:xmlrpc username:loginFields.username password:loginFields.password success:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained failureStub)(NSError *); + [invocation getArgument:&failureStub atIndex:6]; + + failureStub(error); + }]; + }); + + it(@"should display an error", ^{ + [[mockLoginFacadeDelegate expect] displayRemoteError:error]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + }); + }); + + context(@"when not successful", ^{ + + __block NSError *error; + + beforeEach(^{ + error = [NSError errorWithDomain:@"org.wordpress" code:-1 userInfo:@{ NSLocalizedDescriptionKey : @"Error" }]; + + // Intercept failure callback and execute it when appropriate + [OCMStub([mockXMLRPCAPIFacade guessXMLRPCURLForSite:loginFields.siteAddress success:OCMOCK_ANY failure:OCMOCK_ANY]) andDo:^(NSInvocation *invocation) { + void (^ __unsafe_unretained failureStub)(NSError *); + [invocation getArgument:&failureStub atIndex:4]; + + failureStub(error); + }]; + }); + + it(@"should display an error", ^{ + [[mockLoginFacadeDelegate expect] displayRemoteError:error]; + + [loginFacade signInWithLoginFields:loginFields]; + + [mockLoginFacadeDelegate verify]; + }); + }); + }); + }); +}); + +SpecEnd + diff --git a/WordPressAuthenticator/Tests/SingIn/AppleAuthenticatorTests.swift b/WordPressAuthenticator/Tests/SingIn/AppleAuthenticatorTests.swift new file mode 100644 index 000000000000..88ea942e0081 --- /dev/null +++ b/WordPressAuthenticator/Tests/SingIn/AppleAuthenticatorTests.swift @@ -0,0 +1,107 @@ +import AuthenticationServices +@testable import WordPressAuthenticator +import XCTest + +class AppleAuthenticatorTests: XCTestCase { + + // showSignupEpilogue with loginFields.meta.appleUser set will pass SocialService.apple to the delegate + func testShowingSignupEpilogueWithApple() throws { + WordPressAuthenticator.initializeForTesting() + let delegateSpy = WordPressAuthenticatorDelegateSpy() + WordPressAuthenticator.shared.delegate = delegateSpy + + // This might be unnecessary because delegateSpy should be deallocated once the test method finished. + // Leaving it here, just in case. + addTeardownBlock { + WordPressAuthenticator.shared.delegate = nil + } + + let socialUserCreatingStub = SocialUserCreatingStub(appleResult: .success((true, true, true, "a", "b"))) + let sut = AppleAuthenticator(signupService: socialUserCreatingStub) + + // Before acting on the SUT, we need to ensure the login fields are set as we expect + let presenterViewController = UIViewController() + // We need to create this because it's accessed by showFrom(viewController:) + _ = UINavigationController(rootViewController: presenterViewController) + sut.showFrom(viewController: presenterViewController) + sut.createWordPressComUser( + appleUserId: "apple-user-id", + email: "test@email.com", + name: "Full Name", + token: "abcd" + ) + + sut.showSignupEpilogue(for: AuthenticatorCredentials()) + + let service = try XCTUnwrap(delegateSpy.socialUser?.service) + guard case .apple = service else { + return XCTFail("Expected Apple social service, got \(service) instead") + } + } + + // showSignupEpilogue with loginFields.meta.appleUser set will not pass SocialService.apple to the delegate + func testShowingSignupEpilogueWithoutAppleUser() throws { + WordPressAuthenticator.initializeForTesting() + let delegateSpy = WordPressAuthenticatorDelegateSpy() + WordPressAuthenticator.shared.delegate = delegateSpy + + // This might be unnecessary because delegateSpy should be deallocated once the test method finished. + // Leaving it here, just in case. + addTeardownBlock { + WordPressAuthenticator.shared.delegate = nil + } + + let sut = AppleAuthenticator(signupService: SocialUserCreatingStub()) + + // Before acting on the SUT, we need to ensure the login fields are set as we expect + let presenterViewController = UIViewController() + // We need to create this because it's accessed by showFrom(viewController:) + _ = UINavigationController(rootViewController: presenterViewController) + sut.showFrom(viewController: presenterViewController) + + sut.showSignupEpilogue(for: AuthenticatorCredentials()) + + // The delegate is called, but without social service. + // + // By the way, the type system and runtime allow this to happen, but does it actually + // make sense? Not so sure. How can we callback from Sign In with Apple without the + // matching social service? + XCTAssertTrue(delegateSpy.presentSignupEpilogueCalled) + XCTAssertNil(delegateSpy.socialUser) + } +} + +// This doesn't live in a dedicated file because we currently only need it for this test. +class SocialUserCreatingStub: SocialUserCreating { + + // is new account, user name, WPCom token + private let googleResult: Result<(Bool, String, String), Error> + // is new account, existing non-social account, existing MFA account, user name, WPCom token + private let appleResult: Result<(Bool, Bool, Bool, String, String), Error> + + init( + appleResult: Result<(Bool, Bool, Bool, String, String), Error> = .failure(TestError(id: 1)), + googleResult: Result<(Bool, String, String), Error> = .failure(TestError(id: 2)) + ) { + self.appleResult = appleResult + self.googleResult = googleResult + } + + func createWPComUserWithGoogle(token: String, success: @escaping (Bool, String, String) -> Void, failure: @escaping (Error) -> Void) { + switch googleResult { + case .success((let isNewAccount, let userName, let wpComToken)): + success(isNewAccount, userName, wpComToken) + case .failure(let error): + failure(error) + } + } + + func createWPComUserWithApple(token: String, email: String, fullName: String?, success: @escaping (Bool, Bool, Bool, String, String) -> Void, failure: @escaping (Error) -> Void) { + switch appleResult { + case .success((let isNewAccount, let existingNonSocialAccount, let existing2FAAccount, let username, let wpComToken)): + success(isNewAccount, existingNonSocialAccount, existing2FAAccount, username, wpComToken) + case .failure(let error): + failure(error) + } + } +} diff --git a/WordPressAuthenticator/Tests/SingIn/LoginViewControllerTests.swift b/WordPressAuthenticator/Tests/SingIn/LoginViewControllerTests.swift new file mode 100644 index 000000000000..c71c8012d834 --- /dev/null +++ b/WordPressAuthenticator/Tests/SingIn/LoginViewControllerTests.swift @@ -0,0 +1,33 @@ +@testable import WordPressAuthenticator +import XCTest + +class LoginViewControllerTests: XCTestCase { + + // showSignupEpilogue with loginFields.meta.appleUser set will pass SocialService.apple to + // the delegate + func testShowingSignupEpilogueWithGoogleUser() throws { + WordPressAuthenticator.initializeForTesting() + let delegateSpy = WordPressAuthenticatorDelegateSpy() + WordPressAuthenticator.shared.delegate = delegateSpy + + // This might be unnecessary because delegateSpy should be deallocated once the test method finished. + // Leaving it here, just in case. + addTeardownBlock { + WordPressAuthenticator.shared.delegate = nil + } + + let sut = LoginViewController() + // We need to embed the SUT in a navigation controller because it expects its + // navigationController property to not be nil. + _ = UINavigationController(rootViewController: sut) + + sut.loginFields.meta.socialUser = SocialUser(email: "test@email.com", fullName: "Full Name", service: .google) + + sut.showSignupEpilogue(for: AuthenticatorCredentials()) + + let service = try XCTUnwrap(delegateSpy.socialUser?.service) + guard case .google = service else { + return XCTFail("Expected Google social service, got \(service) instead") + } + } +} diff --git a/WordPressAuthenticator/Tests/SingIn/SiteAddressViewModelTests.swift b/WordPressAuthenticator/Tests/SingIn/SiteAddressViewModelTests.swift new file mode 100644 index 000000000000..d74ba5017f9d --- /dev/null +++ b/WordPressAuthenticator/Tests/SingIn/SiteAddressViewModelTests.swift @@ -0,0 +1,125 @@ +import XCTest +import WordPressKit +@testable import WordPressAuthenticator + +final class SiteAddressViewModelTests: XCTestCase { + private var isSiteDiscovery: Bool! + private var xmlrpcFacade: MockWordPressXMLRPCAPIFacade! + private var authenticationDelegateSpy: WordPressAuthenticatorDelegateSpy! + private var blogService: MockWordPressComBlogService! + private var loginFields: LoginFields! + private var viewModel: SiteAddressViewModel! + + override func setUp() { + super.setUp() + isSiteDiscovery = false + xmlrpcFacade = MockWordPressXMLRPCAPIFacade() + authenticationDelegateSpy = WordPressAuthenticatorDelegateSpy() + blogService = MockWordPressComBlogService() + loginFields = LoginFields() + + WordPressAuthenticator.initializeForTesting() + + viewModel = SiteAddressViewModel(isSiteDiscovery: isSiteDiscovery, xmlrpcFacade: xmlrpcFacade, authenticationDelegate: authenticationDelegateSpy, blogService: blogService, loginFields: loginFields) + } + + func testGuessXMLRPCURLSuccess() { + xmlrpcFacade.success = true + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://wordpress.com", loading: { _ in }) { res in + result = res + } + + XCTAssertEqual(result, .success) + } + + func testGuessXMLRPCURLError() { + xmlrpcFacade.error = NSError(domain: "SomeDomain", code: 1, userInfo: nil) + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://error.com", loading: { _ in }) { res in + result = res + } + if case .error(let error, _) = result { + XCTAssertEqual(error.code, 1) + } else { + XCTFail("Unexpected result: \(String(describing: result))") + } + } + + func testGuessXMLRPCURLErrorInvalidNotWP() { + xmlrpcFacade.error = WordPressOrgXMLRPCValidatorError.invalid as NSError + blogService.isWP = false + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://invalid.com", loading: { _ in }) { res in + result = res + } + + if case .error(let error, _) = result { + XCTAssertEqual(error.code, WordPressOrgXMLRPCValidatorError.invalid.rawValue) + } else { + XCTFail("Unexpected result: \(String(describing: result))") + } + } + + func testGuessXMLRPCURLErrorInvalidIsWP() { + xmlrpcFacade.error = WordPressOrgXMLRPCValidatorError.invalid as NSError + blogService.isWP = true + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://invalidwp.com", loading: { _ in }) { res in + result = res + } + if case .error(let error, _) = result { + XCTAssertEqual(error.code, WordPressOrgXMLRPCValidatorError.xmlrpc_missing.rawValue) + } else { + XCTFail("Unexpected result: \(String(describing: result))") + } + } + + func testGuessXMLRPCTroubleshootSite() { + viewModel = SiteAddressViewModel(isSiteDiscovery: true, xmlrpcFacade: xmlrpcFacade, authenticationDelegate: authenticationDelegateSpy, blogService: blogService, loginFields: loginFields) + xmlrpcFacade.error = NSError(domain: "SomeDomain", code: 1, userInfo: nil) + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://troubleshoot.com", loading: { _ in }) { res in + result = res + } + XCTAssertEqual(result, .troubleshootSite) + } + + func testGuessXMLRPCURLErrorHandledByDelegate() { + xmlrpcFacade.error = NSError(domain: "SomeDomain", code: 1, userInfo: nil) + authenticationDelegateSpy.shouldHandleError = true + + var result: SiteAddressViewModel.GuessXMLRPCURLResult? + viewModel.guessXMLRPCURL(for: "https://delegatehandles.com", loading: { _ in }) { res in + result = res + } + + if case .customUI = result { + XCTAssertTrue(true) + } else { + XCTFail("Unexpected result: \(String(describing: result))") + } + } +} + +private class MockWordPressXMLRPCAPIFacade: WordPressXMLRPCAPIFacade { + var success: Bool = false + var error: NSError? + + override func guessXMLRPCURL(forSite siteAddress: String, success: @escaping (URL?) -> (), failure: @escaping (Error?) -> ()) { + if self.success { + success(URL(string: "https://successful.site")) + } else { + failure(self.error) + } + } +} + +private class MockWordPressComBlogService: WordPressComBlogService { + var isWP = false + + override func fetchUnauthenticatedSiteInfoForAddress(for address: String, success: @escaping (WordPressComSiteInfo) -> Void, failure: @escaping (Error) -> Void) { + let siteInfo = WordPressComSiteInfo(remote: ["isWordPress": isWP]) + success(siteInfo) + } +} diff --git a/WordPressAuthenticator/Tests/SupportingFiles/WordPressAuthenticatorTests-Bridging-Header.h b/WordPressAuthenticator/Tests/SupportingFiles/WordPressAuthenticatorTests-Bridging-Header.h new file mode 100644 index 000000000000..e11d920b1208 --- /dev/null +++ b/WordPressAuthenticator/Tests/SupportingFiles/WordPressAuthenticatorTests-Bridging-Header.h @@ -0,0 +1,3 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// diff --git a/WordPressAuthenticator/Tests/UnitTests.xctestplan b/WordPressAuthenticator/Tests/UnitTests.xctestplan new file mode 100644 index 000000000000..86a7b0cfb064 --- /dev/null +++ b/WordPressAuthenticator/Tests/UnitTests.xctestplan @@ -0,0 +1,63 @@ +{ + "configurations" : [ + { + "id" : "D97A5016-3F23-4D19-9CEB-C10A9AC3591F", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "environmentVariableEntries" : [ + { + "key" : "BUILDKITE_ANALYTICS_TOKEN", + "value" : "$(BUILDKITE_ANALYTICS_TOKEN)" + }, + { + "key" : "BUILDKITE_BRANCH", + "value" : "$(BUILDKITE_BRANCH)" + }, + { + "key" : "BUILDKITE_BUILD_ID", + "value" : "$(BUILDKITE_BUILD_ID)" + }, + { + "key" : "BUILDKITE_BUILD_NUMBER", + "value" : "$(BUILDKITE_BUILD_NUMBER)" + }, + { + "key" : "BUILDKITE_BUILD_URL", + "value" : "$(BUILDKITE_BUILD_URL)" + }, + { + "key" : "BUILDKITE_COMMIT", + "value" : "$(BUILDKITE_COMMIT)" + }, + { + "key" : "BUILDKITE_JOB_ID", + "value" : "$(BUILDKITE_JOB_ID)" + }, + { + "key" : "BUILDKITE_MESSAGE", + "value" : "$(BUILDKITE_MESSAGE)" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:WordPressAuthenticator.xcodeproj", + "identifier" : "B5ED78F3207E976500A8FD8C", + "name" : "WordPressAuthenticator" + }, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:WordPressAuthenticator.xcodeproj", + "identifier" : "B5ED78FC207E976500A8FD8C", + "name" : "WordPressAuthenticatorTests" + } + } + ], + "version" : 1 +} diff --git a/WordPressAuthenticator/WordPressAuthenticator.podspec b/WordPressAuthenticator/WordPressAuthenticator.podspec new file mode 100644 index 000000000000..4903b79f3e22 --- /dev/null +++ b/WordPressAuthenticator/WordPressAuthenticator.podspec @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +Pod::Spec.new do |s| + s.name = 'WordPressAuthenticator' + s.version = '9.0.8' + + s.summary = 'WordPressAuthenticator implements an easy and elegant way to authenticate your WordPress Apps.' + s.description = <<-DESC + This framework encapsulates everything required to display the Authentication UI + and perform authentication against WordPress.com and WordPress.org sites. + + Plus: WordPress.com *signup* is supported. + DESC + + s.homepage = 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS' + s.license = { type: 'GPLv2', file: 'LICENSE' } + s.author = { 'The WordPress Mobile Team' => 'mobile@wordpress.org' } + + s.platform = :ios, '13.0' + s.swift_version = '5.0' + + s.source = { git: 'https://github.com/wordpress-mobile/WordPressAuthenticator-iOS.git', + tag: s.version.to_s } + s.source_files = 'WordPressAuthenticator/**/*.{h,m,swift}' + s.private_header_files = 'WordPressAuthenticator/Private/*.h' + s.resource_bundles = { + WordPressAuthenticatorResources: [ + 'WordPressAuthenticator/Resources/Assets.xcassets', + 'WordPressAuthenticator/Resources/SupportedEmailClients/*.plist', + 'WordPressAuthenticator/Resources/Animations/*.json', + 'WordPressAuthenticator/**/*.{storyboard,xib}' + ] + } + s.header_dir = 'WordPressAuthenticator' + + s.dependency 'NSURL+IDN', '0.4' + s.dependency 'SVProgressHUD', '~> 2.2.5' + s.dependency 'Gridicons', '~> 1.0' + + # Use a loose restriction that allows both production and beta versions, up to the next major version. + # If you want to update which of these is used, specify it in the host app. + s.dependency 'WordPressUI', '~> 1.7-beta' + s.dependency 'WordPressKit', '~> 17.0' + s.dependency 'WordPressShared', '~> 2.1-beta' +end diff --git a/WordPressKit/Sources/APIInterface/FilePart.m b/WordPressKit/Sources/APIInterface/FilePart.m new file mode 100644 index 000000000000..5648f04d44c6 --- /dev/null +++ b/WordPressKit/Sources/APIInterface/FilePart.m @@ -0,0 +1,18 @@ +#import "FilePart.h" + +@implementation FilePart + +- (instancetype)initWithParameterName:(NSString *)parameterName + url:(NSURL *)url + fileName:(NSString *)fileName + mimeType:(NSString *)mimeType +{ + self = [super init]; + self.parameterName = parameterName; + self.url = url; + self.fileName = fileName; + self.mimeType = mimeType; + return self; +} + +@end diff --git a/WordPressKit/Sources/APIInterface/WordPressComRESTAPIVersionedPathBuilder.m b/WordPressKit/Sources/APIInterface/WordPressComRESTAPIVersionedPathBuilder.m new file mode 100644 index 000000000000..073dcc0e0bb6 --- /dev/null +++ b/WordPressKit/Sources/APIInterface/WordPressComRESTAPIVersionedPathBuilder.m @@ -0,0 +1,60 @@ +#import +#if SWIFT_PACKAGE +#import "WordPressComRESTAPIVersionedPathBuilder.h" +#import "WordPressComRESTAPIVersion.h" +#else +#import "WordPressKit/WordPressComRESTAPIVersionedPathBuilder.h" +#endif + +static NSString* const WordPressComRESTApiVersionStringInvalid = @"invalid_api_version"; +static NSString* const WordPressComRESTApiVersionString_1_0 = @"rest/v1"; +static NSString* const WordPressComRESTApiVersionString_1_1 = @"rest/v1.1"; +static NSString* const WordPressComRESTApiVersionString_1_2 = @"rest/v1.2"; +static NSString* const WordPressComRESTApiVersionString_1_3 = @"rest/v1.3"; +static NSString* const WordPressComRESTApiVersionString_2_0 = @"wpcom/v2"; + +@implementation WordPressComRESTAPIVersionedPathBuilder + ++ (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +{ + NSString *apiVersionString = [self apiVersionStringWithEnumValue:apiVersion]; + + return [NSString stringWithFormat:@"%@/%@", apiVersionString, endpoint]; +} + ++ (NSString *)apiVersionStringWithEnumValue:(WordPressComRESTAPIVersion)apiVersion +{ + NSString *result = nil; + + switch (apiVersion) { + case WordPressComRESTAPIVersion_1_0: + result = WordPressComRESTApiVersionString_1_0; + break; + + case WordPressComRESTAPIVersion_1_1: + result = WordPressComRESTApiVersionString_1_1; + break; + + case WordPressComRESTAPIVersion_1_2: + result = WordPressComRESTApiVersionString_1_2; + break; + + case WordPressComRESTAPIVersion_1_3: + result = WordPressComRESTApiVersionString_1_3; + break; + + case WordPressComRESTAPIVersion_2_0: + result = WordPressComRESTApiVersionString_2_0; + break; + + default: + NSAssert(NO, @"This should never by executed"); + result = WordPressComRESTApiVersionStringInvalid; + break; + } + + return result; +} + +@end diff --git a/WordPressKit/Sources/APIInterface/include/FilePart.h b/WordPressKit/Sources/APIInterface/include/FilePart.h new file mode 100644 index 000000000000..5590a5fc1404 --- /dev/null +++ b/WordPressKit/Sources/APIInterface/include/FilePart.h @@ -0,0 +1,16 @@ +#import + +/// Represents the infomartion needed to encode a file on a multipart form request. +@interface FilePart: NSObject + +@property (strong, nonatomic) NSString * _Nonnull parameterName; +@property (strong, nonatomic) NSURL * _Nonnull url; +@property (strong, nonatomic) NSString * _Nonnull fileName; +@property (strong, nonatomic) NSString * _Nonnull mimeType; + +- (instancetype _Nonnull)initWithParameterName:(NSString * _Nonnull)parameterName + url:(NSURL * _Nonnull)url + fileName:(NSString * _Nonnull)fileName + mimeType:(NSString * _Nonnull)mimeType; + +@end diff --git a/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIInterfacing.h b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIInterfacing.h new file mode 100644 index 000000000000..1f652fd6cd7e --- /dev/null +++ b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIInterfacing.h @@ -0,0 +1,30 @@ +@import Foundation; + +@class FilePart; + +@protocol WordPressComRESTAPIInterfacing + +@property (strong, nonatomic, readonly) NSURL * _Nonnull baseURL; + +/// - Note: `parameters` has `id` instead of the more common `NSObject *` as its value type so it will convert to `AnyObject` in Swift. +/// In Swift, it's simpler to work with `AnyObject` than with `NSObject`. For example `"abc" as AnyObject` over `"abc" as NSObject`. +- (NSProgress * _Nullable)get:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +/// - Note: `parameters` has `id` instead of the more common `NSObject *` as its value type so it will convert to `AnyObject` in Swift. +/// In Swift, it's simpler to work with `AnyObject` than with `NSObject`. For example `"abc" as AnyObject` over `"abc" as NSObject`. +- (NSProgress * _Nullable)post:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +- (NSProgress * _Nullable)multipartPOST:(NSString * _Nonnull)URLString + parameters:(NSDictionary * _Nullable)parameters + fileParts:(NSArray * _Nonnull)fileParts + requestEnqueued:(void (^ _Nullable)(NSNumber * _Nonnull))requestEnqueue + success:(void (^ _Nonnull)(id _Nonnull, NSHTTPURLResponse * _Nullable))success + failure:(void (^ _Nonnull)(NSError * _Nonnull, NSHTTPURLResponse * _Nullable))failure; + +@end diff --git a/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersion.h b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersion.h new file mode 100644 index 000000000000..9625473ee97a --- /dev/null +++ b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersion.h @@ -0,0 +1,9 @@ +#import + +typedef NS_ENUM(NSInteger, WordPressComRESTAPIVersion) { + WordPressComRESTAPIVersion_1_0 = 1000, + WordPressComRESTAPIVersion_1_1 = 1001, + WordPressComRESTAPIVersion_1_2 = 1002, + WordPressComRESTAPIVersion_1_3 = 1003, + WordPressComRESTAPIVersion_2_0 = 2000 +}; diff --git a/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersionedPathBuilder.h b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersionedPathBuilder.h new file mode 100644 index 000000000000..d9f8b8162826 --- /dev/null +++ b/WordPressKit/Sources/APIInterface/include/WordPressComRESTAPIVersionedPathBuilder.h @@ -0,0 +1,14 @@ +#import +#if SWIFT_PACKAGE +#import "WordPressComRESTAPIVersion.h" +#else +#import +#endif + +@interface WordPressComRESTAPIVersionedPathBuilder: NSObject + ++ (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +NS_SWIFT_NAME(path(forEndpoint:withVersion:)); + +@end diff --git a/WordPressKit/Sources/APIInterface/include/WordPressComRestApiErrorDomain.h b/WordPressKit/Sources/APIInterface/include/WordPressComRestApiErrorDomain.h new file mode 100644 index 000000000000..c4b6aa423b93 --- /dev/null +++ b/WordPressKit/Sources/APIInterface/include/WordPressComRestApiErrorDomain.h @@ -0,0 +1,9 @@ +#import + +/// Error domain of `NSError` instances that are converted from `WordPressComRestApiEndpointError` +/// and `WordPressAPIError` instances. +/// +/// This matches the compiler generated value and is used to ensure consistent error domain across error types and SPM or Framework build modes. +/// +/// See `extension WordPressComRestApiEndpointError: CustomNSError` in CoreAPI package for context. +static NSString *const _Nonnull WordPressComRestApiErrorDomain = @"WordPressKit.WordPressComRestApiError"; diff --git a/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.h b/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.h new file mode 100644 index 000000000000..6d7ad96ab281 --- /dev/null +++ b/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.h @@ -0,0 +1,52 @@ +#import +#import +#import + +@class WordPressComRestApi; + +NS_ASSUME_NONNULL_BEGIN + +/** + * @class ServiceRemoteREST + * @brief Parent class for all REST service classes. + */ +@interface ServiceRemoteWordPressComREST : NSObject + +/** + * @brief The API object to use for communications. + */ +// TODO: This needs to go before being able to put this ObjC in a package. +@property (nonatomic, strong, readonly) WordPressComRestApi *wordPressComRestApi; + +/** + * @brief The interface to the WordPress.com API to use for performing REST requests. + * This is meant to gradually replace `wordPressComRestApi`. + */ +@property (nonatomic, strong, readonly) id wordPressComRESTAPI; + +/** + * @brief Designated initializer. + * + * @param api The API to use for communication. Cannot be nil. + * + * @returns The initialized object. + */ +- (instancetype)initWithWordPressComRestApi:(WordPressComRestApi *)api; + +#pragma mark - Request URL construction + +/** + * @brief Constructs the request URL for the specified API version and specified resource URL. + * + * @param endpoint The URL of the resource for the request. Cannot be nil. + * @param apiVersion The version of the API to use. + * + * @returns The request URL. + */ +- (NSString *)pathForEndpoint:(NSString *)endpoint + withVersion:(WordPressComRESTAPIVersion)apiVersion +NS_SWIFT_NAME(path(forEndpoint:withVersion:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.m b/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.m new file mode 100644 index 000000000000..b3f1f359a671 --- /dev/null +++ b/WordPressKit/Sources/BasicBlogAPIObjc/ServiceRemoteWordPressComREST.m @@ -0,0 +1,29 @@ +#import "ServiceRemoteWordPressComREST.h" +#import "WPKit-Swift.h" + +@implementation ServiceRemoteWordPressComREST + +- (instancetype)initWithWordPressComRestApi:(WordPressComRestApi *)wordPressComRestApi { + + NSParameterAssert([wordPressComRestApi isKindOfClass:[WordPressComRestApi class]]); + + self = [super init]; + if (self) { + _wordPressComRestApi = wordPressComRestApi; + _wordPressComRESTAPI = wordPressComRestApi; + } + return self; +} + +#pragma mark - Request URL construction + +- (NSString *)pathForEndpoint:(NSString *)resourceUrl + withVersion:(WordPressComRESTAPIVersion)apiVersion +{ + NSParameterAssert([resourceUrl isKindOfClass:[NSString class]]); + + return [WordPressComRESTAPIVersionedPathBuilder pathForEndpoint:resourceUrl + withVersion:apiVersion]; +} + +@end diff --git a/WordPressKit/Sources/CoreAPI/AppTransportSecuritySettings.swift b/WordPressKit/Sources/CoreAPI/AppTransportSecuritySettings.swift new file mode 100644 index 000000000000..a8baa6682af4 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/AppTransportSecuritySettings.swift @@ -0,0 +1,75 @@ +import Foundation + +/// A dependency of `AppTransportSecuritySettings` generally used for injection in unit tests. +/// +/// Only `Bundle` would conform to this `protocol`. +protocol InfoDictionaryObjectProvider { + func object(forInfoDictionaryKey key: String) -> Any? +} + +extension Bundle: InfoDictionaryObjectProvider { + +} + +/// Provides a simpler interface to the `Bundle` (`Info.plist`) settings under the +/// `NSAppTransportSecurity` key. +struct AppTransportSecuritySettings { + + private let infoDictionaryObjectProvider: InfoDictionaryObjectProvider + + private var settings: NSDictionary? { + infoDictionaryObjectProvider.object(forInfoDictionaryKey: "NSAppTransportSecurity") as? NSDictionary + } + + private var exceptionDomains: NSDictionary? { + settings?["NSExceptionDomains"] as? NSDictionary + } + + init(_ infoDictionaryObjectProvider: InfoDictionaryObjectProvider = Bundle.main) { + self.infoDictionaryObjectProvider = infoDictionaryObjectProvider + } + + /// Returns whether the `NSAppTransportSecurity` settings indicate that access to the + /// given `siteURL` should be through SSL/TLS only. + /// + /// Secure access is the default that is set by Apple. But the hosting app is allowed to + /// override this for specific or for all domains. This method encapsulates the logic for + /// reading the `Bundle` (`Info.plist`) settings and translating the rules and conditions + /// described in the + /// [NSAppTransportSecurity](https://developer.apple.com/documentation/bundleresources/information_property_list/nsapptransportsecurity) + /// documentation and its sub-pages. + func secureAccessOnly(for siteURL: URL) -> Bool { + // From Apple: If you specify an exception domain dictionary, ATS ignores any global + // configuration keys, like NSAllowsArbitraryLoads, for that domain. This is true even + // if you leave the domain-specific dictionary empty and rely entirely on its keys’ default + // values. + if let exceptionDomain = self.exceptionDomain(for: siteURL) { + let allowsInsecureHTTPLoads = + exceptionDomain["NSExceptionAllowsInsecureHTTPLoads"] as? Bool ?? false + return !allowsInsecureHTTPLoads + } + + guard let settings = settings else { + return true + } + + // From Apple: The value of the `NSAllowsArbitraryLoads` key is ignored—and the default value of + // NO used instead—if any of the following keys are present: + guard settings["NSAllowsLocalNetworking"] == nil && + settings["NSAllowsArbitraryLoadsForMedia"] == nil && + settings["NSAllowsArbitraryLoadsInWebContent"] == nil else { + return true + } + + let allowsArbitraryLoads = settings["NSAllowsArbitraryLoads"] as? Bool ?? false + return !allowsArbitraryLoads + } + + private func exceptionDomain(for siteURL: URL) -> NSDictionary? { + guard let domain = siteURL.host?.lowercased() else { + return nil + } + + return exceptionDomains?[domain] as? NSDictionary + } +} diff --git a/WordPressKit/Sources/CoreAPI/Date+WordPressCom.swift b/WordPressKit/Sources/CoreAPI/Date+WordPressCom.swift new file mode 100644 index 000000000000..3bbc47e053ad --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/Date+WordPressCom.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Date { + + /// Parses a date string + /// + /// Dates in the format specified in http://www.w3.org/TR/NOTE-datetime should be OK. + /// The kind of dates returned by the REST API should match that format, even if the doc promises ISO 8601. + /// + /// Parsing the full ISO 8601, or even RFC 3339 is more complex than this, and makes no sense right now. + /// + /// - SeeAlso: [WordPress.com REST API docs](https://developer.wordpress.com/docs/api/) + /// - Warning: This method doesn't support fractional seconds or dates with leap seconds (23:59:60 turns into 23:59:00) + static func with(wordPressComJSONString jsonString: String) -> Date? { + DateFormatter.wordPressCom.date(from: jsonString) + } + + var wordPressComJSONString: String { + DateFormatter.wordPressCom.string(from: self) + } +} diff --git a/WordPressKit/Sources/CoreAPI/DateFormatter+WordPressCom.swift b/WordPressKit/Sources/CoreAPI/DateFormatter+WordPressCom.swift new file mode 100644 index 000000000000..930b87f830f2 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/DateFormatter+WordPressCom.swift @@ -0,0 +1,15 @@ +import Foundation + +extension DateFormatter { + + /// A `DateFormatter` configured to manage dates compatible with the WordPress.com API. + /// + /// - SeeAlso: [https://developer.wordpress.com/docs/api/](https://developer.wordpress.com/docs/api/) + static let wordPressCom: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ssZ" + formatter.timeZone = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") as Locale + return formatter + }() +} diff --git a/WordPressKit/Sources/CoreAPI/Either.swift b/WordPressKit/Sources/CoreAPI/Either.swift new file mode 100644 index 000000000000..4b895f7a8b7c --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/Either.swift @@ -0,0 +1,15 @@ +import Foundation + +enum Either { + case left(L) + case right(R) + + func map(left: (L) -> T, right: (R) -> T) -> T { + switch self { + case let .left(value): + return left(value) + case let .right(value): + return right(value) + } + } +} diff --git a/WordPressKit/Sources/CoreAPI/HTTPAuthenticationAlertController.swift b/WordPressKit/Sources/CoreAPI/HTTPAuthenticationAlertController.swift new file mode 100644 index 000000000000..c588fa0585a5 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/HTTPAuthenticationAlertController.swift @@ -0,0 +1,104 @@ +import Foundation +import UIKit + +/// URLAuthenticationChallenge Handler: It's up to the Host App to actually use this, whenever `WordPressOrgXMLRPCApi.onChallenge` is hit! +/// +open class HTTPAuthenticationAlertController { + + public typealias AuthenticationHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + + private static var onGoingChallenges = [URLProtectionSpace: [AuthenticationHandler]]() + + static public func controller(for challenge: URLAuthenticationChallenge, handler: @escaping AuthenticationHandler) -> UIAlertController? { + if var handlers = onGoingChallenges[challenge.protectionSpace] { + handlers.append(handler) + onGoingChallenges[challenge.protectionSpace] = handlers + return nil + } + onGoingChallenges[challenge.protectionSpace] = [handler] + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + return controllerForServerTrustChallenge(challenge) + default: + return controllerForUserAuthenticationChallenge(challenge) + } + } + + static func executeHandlerForChallenge(_ challenge: URLAuthenticationChallenge, disposition: URLSession.AuthChallengeDisposition, credential: URLCredential?) { + guard let handlers = onGoingChallenges[challenge.protectionSpace] else { + return + } + for handler in handlers { + handler(disposition, credential) + } + onGoingChallenges.removeValue(forKey: challenge.protectionSpace) + } + + private static func controllerForServerTrustChallenge(_ challenge: URLAuthenticationChallenge) -> UIAlertController { + let title = NSLocalizedString("Certificate error", comment: "Popup title for wrong SSL certificate.") + let localizedMessage = NSLocalizedString( + "The certificate for this server is invalid. You might be connecting to a server that is pretending to be “%@” which could put your confidential information at risk.\n\nWould you like to trust the certificate anyway?", + comment: "Message for when the certificate for the server is invalid. The %@ placeholder will be replaced the a host name, received from the API." + ) + let message = String(format: localizedMessage, challenge.protectionSpace.host) + let controller = UIAlertController(title: title, message: message, preferredStyle: .alert) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button label"), + style: .default, + handler: { (_) in + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + }) + controller.addAction(cancelAction) + + let trustAction = UIAlertAction(title: NSLocalizedString("Trust", comment: "Connect when the SSL certificate is invalid"), + style: .default, + handler: { (_) in + let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!) + URLCredentialStorage.shared.setDefaultCredential(credential, for: challenge.protectionSpace) + executeHandlerForChallenge(challenge, disposition: .useCredential, credential: credential) + }) + controller.addAction(trustAction) + return controller + } + + private static func controllerForUserAuthenticationChallenge(_ challenge: URLAuthenticationChallenge) -> UIAlertController { + let title = String(format: NSLocalizedString("Authentication required for host: %@", comment: "Popup title to ask for user credentials."), challenge.protectionSpace.host) + let message = NSLocalizedString("Please enter your credentials", comment: "Popup message to ask for user credentials (fields shown below).") + let controller = UIAlertController(title: title, + message: message, + preferredStyle: .alert) + + controller.addTextField( configurationHandler: { (textField) in + textField.placeholder = NSLocalizedString("Username", comment: "Login dialog username placeholder") + }) + + controller.addTextField(configurationHandler: { (textField) in + textField.placeholder = NSLocalizedString("Password", comment: "Login dialog password placeholder") + textField.isSecureTextEntry = true + }) + + let cancelAction = UIAlertAction(title: NSLocalizedString("Cancel", comment: "Cancel button label"), + style: .default, + handler: { (_) in + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + }) + controller.addAction(cancelAction) + + let loginAction = UIAlertAction(title: NSLocalizedString("Log In", comment: "Log In button label."), + style: .default, + handler: { (_) in + guard let username = controller.textFields?.first?.text, + let password = controller.textFields?.last?.text else { + executeHandlerForChallenge(challenge, disposition: .cancelAuthenticationChallenge, credential: nil) + return + } + let credential = URLCredential(user: username, password: password, persistence: URLCredential.Persistence.permanent) + URLCredentialStorage.shared.setDefaultCredential(credential, for: challenge.protectionSpace) + executeHandlerForChallenge(challenge, disposition: .useCredential, credential: credential) + }) + controller.addAction(loginAction) + return controller + } + +} diff --git a/WordPressKit/Sources/CoreAPI/HTTPClient.swift b/WordPressKit/Sources/CoreAPI/HTTPClient.swift new file mode 100644 index 000000000000..267d676ecc8b --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/HTTPClient.swift @@ -0,0 +1,336 @@ +import Foundation +import Combine + +public typealias WordPressAPIResult = Result> + +public struct HTTPAPIResponse { + public var response: HTTPURLResponse + public var body: Body +} + +extension HTTPAPIResponse where Body == Data { + var bodyText: String? { + var encoding: String.Encoding? + if let charset = response.textEncodingName { + encoding = String.Encoding(ianaCharsetName: charset) + } + + let defaultEncoding = String.Encoding.isoLatin1 + return String(data: body, encoding: encoding ?? defaultEncoding) + } +} + +extension URLSession { + + /// Create a background URLSession instance that can be used in the `perform(request:...)` async function. + /// + /// The `perform(request:...)` async function can be used in all non-background `URLSession` instances without any + /// extra work. However, there is a requirement to make the function works with with background `URLSession` instances. + /// That is the `URLSession` must have a delegate of `BackgroundURLSessionDelegate` type. + static func backgroundSession(configuration: URLSessionConfiguration) -> URLSession { + assert(configuration.identifier != nil) + // Pass `delegateQueue: nil` to get a serial queue, which is required to ensure thread safe access to + // `WordPressKitSessionDelegate` instances. + return URLSession(configuration: configuration, delegate: BackgroundURLSessionDelegate(), delegateQueue: nil) + } + + /// Send a HTTP request and return its response as a `WordPressAPIResult` instance. + /// + /// ## Progress Tracking and Cancellation + /// + /// You can track the HTTP request's overall progress by passing a `Progress` instance to the `fulfillingProgress` + /// parameter, which must satisify following requirements: + /// - `totalUnitCount` must not be zero. + /// - `completedUnitCount` must be zero. + /// - It's used exclusivity for tracking the HTTP request overal progress: No children in its progress tree. + /// - `cancellationHandler` must be nil. You can call `fulfillingProgress.cancel()` to cancel the ongoing HTTP request. + /// + /// Upon completion, the HTTP request's progress fulfills the `fulfillingProgress`. + /// + /// - Parameters: + /// - builder: A `HTTPRequestBuilder` instance that represents an HTTP request to be sent. + /// - acceptableStatusCodes: HTTP status code ranges that are considered a successful response. Responses with + /// a status code outside of these ranges are returned as a `WordPressAPIResult.unacceptableStatusCode` instance. + /// - parentProgress: A `Progress` instance that will be used as the parent progress of the HTTP request's overall + /// progress. See the function documentation regarding requirements on this argument. + /// - errorType: The concret endpoint error type. + func perform( + request builder: HTTPRequestBuilder, + acceptableStatusCodes: [ClosedRange] = [200...299], + taskCreated: ((Int) -> Void)? = nil, + fulfilling parentProgress: Progress? = nil, + errorType: E.Type = E.self + ) async -> WordPressAPIResult, E> { + if configuration.identifier != nil { + assert(delegate is BackgroundURLSessionDelegate, "Unexpected `URLSession` delegate type. See the `backgroundSession(configuration:)`") + } + + if let parentProgress { + assert(parentProgress.completedUnitCount == 0 && parentProgress.totalUnitCount > 0, "Invalid parent progress") + assert(parentProgress.cancellationHandler == nil, "The progress instance's cancellationHandler property must be nil") + } + + return await withCheckedContinuation { continuation in + let completion: @Sendable (Data?, URLResponse?, Error?) -> Void = { data, response, error in + let result: WordPressAPIResult, E> = Self.parseResponse( + data: data, + response: response, + error: error, + acceptableStatusCodes: acceptableStatusCodes + ) + + continuation.resume(returning: result) + } + + let task: URLSessionTask + + do { + task = try self.task(for: builder, completion: completion) + } catch { + continuation.resume(returning: .failure(.requestEncodingFailure(underlyingError: error))) + return + } + + task.resume() + taskCreated?(task.taskIdentifier) + + if let parentProgress, parentProgress.totalUnitCount > parentProgress.completedUnitCount { + let pending = parentProgress.totalUnitCount - parentProgress.completedUnitCount + // The Jetpack/WordPress app requires task progress updates to be delievered on the main queue. + let progressUpdator = parentProgress.update(totalUnit: pending, with: task.progress, queue: .main) + + parentProgress.cancellationHandler = { [weak task] in + task?.cancel() + progressUpdator.cancel() + } + } + } + } + + private func task( + for builder: HTTPRequestBuilder, + completion originalCompletion: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) throws -> URLSessionTask { + var request = try builder.build(encodeBody: false) + + // This additional `callCompletionFromDelegate` is added to unit test `BackgroundURLSessionDelegate`. + // Background `URLSession` doesn't work on unit tests, we have to create a non-background `URLSession` + // which has a `BackgroundURLSessionDelegate` delegate in order to test `BackgroundURLSessionDelegate`. + // + // In reality, `callCompletionFromDelegate` and `isBackgroundSession` have the same value. + let callCompletionFromDelegate = delegate is BackgroundURLSessionDelegate + let isBackgroundSession = configuration.identifier != nil + let task: URLSessionTask + let body = try builder.encodeMultipartForm(request: &request, forceWriteToFile: isBackgroundSession) + ?? builder.encodeXMLRPC(request: &request, forceWriteToFile: isBackgroundSession) + var completion = originalCompletion + if let body { + // Use special `URLSession.uploadTask` API for multipart POST requests. + task = body.map( + left: { + if callCompletionFromDelegate { + return uploadTask(with: request, from: $0) + } else { + return uploadTask(with: request, from: $0, completionHandler: completion) + } + }, + right: { tempFileURL in + // Remove the temp file, which contains request body, once the HTTP request completes. + completion = { data, response, error in + try? FileManager.default.removeItem(at: tempFileURL) + originalCompletion(data, response, error) + } + + if callCompletionFromDelegate { + return uploadTask(with: request, fromFile: tempFileURL) + } else { + return uploadTask(with: request, fromFile: tempFileURL, completionHandler: completion) + } + } + ) + } else { + // Use `URLSession.dataTask` for all other request + if callCompletionFromDelegate { + task = dataTask(with: request) + } else { + task = dataTask(with: request, completionHandler: completion) + } + } + + if callCompletionFromDelegate { + assert(delegate is BackgroundURLSessionDelegate, "Unexpected `URLSession` delegate type. See the `backgroundSession(configuration:)`") + + set(completion: completion, forTaskWithIdentifier: task.taskIdentifier) + } + + return task + } + + private static func parseResponse( + data: Data?, + response: URLResponse?, + error: Error?, + acceptableStatusCodes: [ClosedRange] + ) -> WordPressAPIResult, E> { + let result: WordPressAPIResult, E> + + if let error { + if let urlError = error as? URLError { + result = .failure(.connection(urlError)) + } else { + result = .failure(.unknown(underlyingError: error)) + } + } else { + if let httpResponse = response as? HTTPURLResponse { + if acceptableStatusCodes.contains(where: { $0 ~= httpResponse.statusCode }) { + result = .success(HTTPAPIResponse(response: httpResponse, body: data ?? Data())) + } else { + result = .failure(.unacceptableStatusCode(response: httpResponse, body: data ?? Data())) + } + } else { + result = .failure(.unparsableResponse(response: nil, body: data)) + } + } + + return result + } + +} + +extension WordPressAPIResult { + + func mapSuccess( + _ transform: (Success) throws -> NewSuccess + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + flatMap { success in + do { + return try .success(transform(success)) + } catch { + return .failure(.unparsableResponse(response: success.response, body: success.body, underlyingError: error)) + } + } + } + + func decodeSuccess( + _ decoder: JSONDecoder = JSONDecoder(), + type: NewSuccess.Type = NewSuccess.self + ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { + mapSuccess { + try decoder.decode(type, from: $0.body) + } + } + + func mapUnacceptableStatusCodeError( + _ transform: (HTTPURLResponse, Data) throws -> E + ) -> WordPressAPIResult where Failure == WordPressAPIError { + mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + do { + return try WordPressAPIError.endpointError(transform(response, body)) + } catch { + return WordPressAPIError.unparsableResponse(response: response, body: body, underlyingError: error) + } + } + return error + } + } + + func mapUnacceptableStatusCodeError( + _ decoder: JSONDecoder = JSONDecoder() + ) -> WordPressAPIResult where E: LocalizedError, E: Decodable, Failure == WordPressAPIError { + mapUnacceptableStatusCodeError { _, body in + try decoder.decode(E.self, from: body) + } + } + +} + +extension Progress { + func update(totalUnit: Int64, with progress: Progress, queue: DispatchQueue) -> AnyCancellable { + let start = self.completedUnitCount + return progress.publisher(for: \.fractionCompleted, options: .new) + .receive(on: queue) + .sink { [weak self] fraction in + self?.completedUnitCount = start + Int64(fraction * Double(totalUnit)) + } + } +} + +// MARK: - Background URL Session Support + +private final class SessionTaskData { + var responseBody = Data() + var completion: ((Data?, URLResponse?, Error?) -> Void)? +} + +class BackgroundURLSessionDelegate: NSObject, URLSessionDataDelegate { + + private var taskData = [Int: SessionTaskData]() + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + session.received(data, forTaskWithIdentifier: dataTask.taskIdentifier) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + session.completed(with: error, response: task.response, forTaskWithIdentifier: task.taskIdentifier) + } + +} + +private extension URLSession { + + static var taskDataKey = 0 + + // A map from `URLSessionTask` identifier to in-memory data of the given task. + // + // This property is in `URLSession` not `BackgroundURLSessionDelegate` because task id (the key) is unique within + // the context of a `URLSession` instance. And in theory `BackgroundURLSessionDelegate` can be used by multiple + // `URLSession` instances. + var taskData: [Int: SessionTaskData] { + get { + objc_getAssociatedObject(self, &URLSession.taskDataKey) as? [Int: SessionTaskData] ?? [:] + } + set { + objc_setAssociatedObject(self, &URLSession.taskDataKey, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + func updateData(forTaskWithIdentifier taskID: Int, using closure: (SessionTaskData) -> Void) { + let task = self.taskData[taskID] ?? SessionTaskData() + closure(task) + self.taskData[taskID] = task + } + + func set(completion: @escaping (Data?, URLResponse?, Error?) -> Void, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { + $0.completion = completion + } + } + + func received(_ data: Data, forTaskWithIdentifier taskID: Int) { + updateData(forTaskWithIdentifier: taskID) { task in + task.responseBody.append(data) + } + } + + func completed(with error: Error?, response: URLResponse?, forTaskWithIdentifier taskID: Int) { + guard let task = taskData[taskID] else { + return + } + + if let error { + task.completion?(nil, response, error) + } else { + task.completion?(task.responseBody, response, nil) + } + + self.taskData.removeValue(forKey: taskID) + } + +} + +extension URLSession { + var debugNumberOfTaskData: Int { + self.taskData.count + } +} diff --git a/WordPressKit/Sources/CoreAPI/HTTPRequestBuilder.swift b/WordPressKit/Sources/CoreAPI/HTTPRequestBuilder.swift new file mode 100644 index 000000000000..1cf8170bba85 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/HTTPRequestBuilder.swift @@ -0,0 +1,333 @@ +import Foundation +import wpxmlrpc + +/// A builder type that appends HTTP request data to a URL. +/// +/// Calling this class's url related functions (the ones that changes path, query, etc) does not modify the +/// original URL string. The URL will be perserved in the final result that's returned by the `build` function. +final class HTTPRequestBuilder { + enum Method: String, CaseIterable { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" + + var allowsHTTPBody: Bool { + self == .post || self == .put || self == .patch + } + } + + private let original: URLComponents + private(set) var method: Method = .get + private var appendedPath: String = "" + private var headers: [String: String] = [:] + private var defaultQuery: [URLQueryItem] = [] + private var appendedQuery: [URLQueryItem] = [] + private var bodyBuilder: ((inout URLRequest) throws -> Void)? + private(set) var multipartForm: [MultipartFormField]? + private(set) var xmlrpcRequest: XMLRPCRequest? + + init(url: URL) { + assert(url.scheme == "http" || url.scheme == "https") + assert(url.host != nil) + + original = URLComponents(url: url, resolvingAgainstBaseURL: true)! + } + + func method(_ method: Method) -> Self { + self.method = method + return self + } + + /// Append path to the original URL. + /// + /// The argument will be appended to the original URL as it is. + func append(percentEncodedPath path: String) -> Self { + assert(!path.contains("?") && !path.contains("#"), "Path should not have query or fragment: \(path)") + + appendedPath = Self.join(appendedPath, path) + + return self + } + + /// Append path and query to the original URL. + /// + /// Some may call API client using a string that contains path and query, like `api.get("post?id=1")`. + /// This function can be used to support those use cases. + func appendURLString(_ string: String) -> Self { + let urlString = Self.join("https://w.org", string) + guard let url = URL(string: urlString), + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) + else { + assertionFailure("Illegal URL string: \(string)") + return self + } + + return append(percentEncodedPath: urlComponents.percentEncodedPath) + .append(query: urlComponents.queryItems ?? []) + } + + func headers(_ headers: [String: String]) -> Self { + for (key, value) in headers { + self.headers[key] = value + } + return self + } + + func header(name: String, value: String?) -> Self { + headers[name] = value + return self + } + + func query(defaults: [URLQueryItem]) -> Self { + defaultQuery = defaults + return self + } + + func query(name: String, value: String?, override: Bool = false) -> Self { + append(query: [URLQueryItem(name: name, value: value)], override: override) + } + + func query(_ parameters: [String: Any]) -> Self { + append(query: parameters.flatten(), override: false) + } + + func append(query: [URLQueryItem], override: Bool = false) -> Self { + if override { + let newKeys = Set(query.map { $0.name }) + appendedQuery.removeAll(where: { newKeys.contains($0.name) }) + } + + appendedQuery.append(contentsOf: query) + + return self + } + + func body(form: [String: Any]) -> Self { + headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8" + bodyBuilder = { req in + let content = form.flatten().percentEncoded + req.httpBody = content.data(using: .utf8) + } + return self + } + + func body(form: [MultipartFormField]) -> Self { + // Unlike other similar functions, multipart form encoding is handled by the `build` function. + multipartForm = form + return self + } + + func body(json: Encodable, jsonEncoder: JSONEncoder = JSONEncoder()) -> Self { + body(json: { + try jsonEncoder.encode(json) + }) + } + + func body(json: Any) -> Self { + body(json: { + try JSONSerialization.data(withJSONObject: json) + }) + } + + func body(json: @escaping () throws -> Data) -> Self { + // 'charset' parameter is not required for json body. See https://www.rfc-editor.org/rfc/rfc8259.html#section-11 + headers["Content-Type"] = "application/json" + bodyBuilder = { req in + req.httpBody = try json() + } + return self + } + + func body(xml: @escaping () throws -> Data) -> Self { + headers["Content-Type"] = "text/xml; charset=utf-8" + bodyBuilder = { req in + req.httpBody = try xml() + } + return self + } + + func build(encodeBody: Bool = false) throws -> URLRequest { + var components = original + + var newPath = Self.join(components.percentEncodedPath, appendedPath) + if !newPath.isEmpty, !newPath.hasPrefix("/") { + newPath = "/\(newPath)" + } + components.percentEncodedPath = newPath + + // Add default query items if they don't exist in `appendedQuery`. + var newQuery = appendedQuery + if !defaultQuery.isEmpty { + let allQuery = (original.queryItems ?? []) + newQuery + let toBeAdded = defaultQuery.filter { item in + !allQuery.contains(where: { $0.name == item.name}) + } + newQuery.append(contentsOf: toBeAdded) + } + + // Bypass `URLComponents`'s URL query encoding, use our own implementation instead. + if !newQuery.isEmpty { + components.percentEncodedQuery = Self.join(components.percentEncodedQuery ?? "", newQuery.percentEncoded, separator: "&") + } + + guard let url = components.url else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + for (header, value) in headers { + request.addValue(value, forHTTPHeaderField: header) + } + + if encodeBody { + let body = try encodeMultipartForm(request: &request, forceWriteToFile: false) ?? encodeXMLRPC(request: &request, forceWriteToFile: false) + if let body { + switch body { + case let .left(data): + request.httpBody = data + case let .right(url): + request.httpBodyStream = InputStream(url: url) + } + } + } + + if let bodyBuilder { + assert(method.allowsHTTPBody, "Can't include body in HTTP \(method.rawValue) requests") + try bodyBuilder(&request) + } + + return request + } + + func encodeMultipartForm(request: inout URLRequest, forceWriteToFile: Bool) throws -> Either? { + guard let multipartForm, !multipartForm.isEmpty else { + return nil + } + + let boundery = String(format: "wordpresskit.%08x", Int.random(in: Int.min.. Either? { + guard let xmlrpcRequest else { + return nil + } + + request.setValue("text/xml", forHTTPHeaderField: "Content-Type") + let encoder = WPXMLRPCEncoder(method: xmlrpcRequest.method, andParameters: xmlrpcRequest.parameters) + if forceWriteToFile { + let fileName = "\(UUID().uuidString).xmlrpc" + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName) + try encoder.encode(toFile: fileURL.path) + + var fileSize: AnyObject? + try (fileURL as NSURL).getResourceValue(&fileSize, forKey: .fileSizeKey) + if let fileSize = fileSize as? NSNumber { + request.setValue(fileSize.stringValue, forHTTPHeaderField: "Content-Length") + } + + return .right(fileURL) + } else { + let data = try encoder.dataEncoded() + request.setValue("\(data.count)", forHTTPHeaderField: "Content-Length") + return .left(data) + } + } +} + +extension HTTPRequestBuilder { + func body(xmlrpc method: String, parameters: [Any]? = nil) -> Self { + self.xmlrpcRequest = XMLRPCRequest(method: method, parameters: parameters) + return self + } +} + +extension HTTPRequestBuilder { + static func urlEncode(_ text: String) -> String { + let specialCharacters = ":#[]@!$&'()*+,;=" + let allowed = CharacterSet.urlQueryAllowed.subtracting(.init(charactersIn: specialCharacters)) + return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text + } + + /// Join a list of strings using a separator only if neighbour items aren't already separated with the given separator. + static func join(_ aList: String..., separator: String = "/") -> String { + guard !aList.isEmpty else { return "" } + + var list = aList + let start = list.removeFirst() + return list.reduce(into: start) { result, path in + guard !path.isEmpty else { return } + + guard !result.isEmpty else { + result = path + return + } + + switch (result.hasSuffix(separator), path.hasPrefix(separator)) { + case (true, true): + var prefixRemoved = path + prefixRemoved.removePrefix(separator) + result.append(prefixRemoved) + case (true, false), (false, true): + result.append(path) + case (false, false): + result.append("\(separator)\(path)") + } + } + } +} + +private extension Dictionary where Key == String, Value == Any { + + static func urlEncode(into result: inout [URLQueryItem], name: String, value: Any) { + switch value { + case let array as [Any]: + for value in array { + urlEncode(into: &result, name: "\(name)[]", value: value) + } + case let object as [String: Any]: + for (key, value) in object { + urlEncode(into: &result, name: "\(name)[\(key)]", value: value) + } + case let value as Bool: + urlEncode(into: &result, name: name, value: value ? "1" : "0") + default: + result.append(URLQueryItem(name: name, value: "\(value)")) + } + } + + func flatten() -> [URLQueryItem] { + sorted { $0.key < $1.key } + .reduce(into: []) { result, entry in + Self.urlEncode(into: &result, name: entry.key, value: entry.value) + } + } + +} + +extension Array where Element == URLQueryItem { + + var percentEncoded: String { + map { + let name = HTTPRequestBuilder.urlEncode($0.name) + guard let value = $0.value else { + return name + } + + return "\(name)=\(HTTPRequestBuilder.urlEncode(value))" + } + .joined(separator: "&") + } + +} + +struct XMLRPCRequest { + var method: String + var parameters: [Any]? +} diff --git a/WordPressKit/Sources/CoreAPI/MultipartForm.swift b/WordPressKit/Sources/CoreAPI/MultipartForm.swift new file mode 100644 index 000000000000..b5bf42baea01 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/MultipartForm.swift @@ -0,0 +1,159 @@ +import Foundation + +enum MultipartFormError: Swift.Error { + case inaccessbileFile(path: String) + case impossible +} + +struct MultipartFormField { + let name: String + let filename: String? + let mimeType: String? + let bytes: UInt64 + + fileprivate let inputStream: InputStream + + init(text: String, name: String, filename: String? = nil, mimeType: String? = nil) { + self.init(data: text.data(using: .utf8)!, name: name, filename: filename, mimeType: mimeType) + } + + init(data: Data, name: String, filename: String? = nil, mimeType: String? = nil) { + self.inputStream = InputStream(data: data) + self.name = name + self.filename = filename + self.bytes = UInt64(data.count) + self.mimeType = mimeType + } + + init(fileAtPath path: String, name: String, filename: String? = nil, mimeType: String? = nil) throws { + guard let inputStream = InputStream(fileAtPath: path), + let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let bytes = (attrs[FileAttributeKey.size] as? NSNumber)?.uint64Value else { + throw MultipartFormError.inaccessbileFile(path: path) + } + self.inputStream = inputStream + self.name = name + self.filename = filename ?? path.split(separator: "/").last.flatMap({ String($0) }) + self.bytes = bytes + self.mimeType = mimeType + } +} + +extension Array where Element == MultipartFormField { + private func multipartFormDestination(forceWriteToFile: Bool) throws -> (outputStream: OutputStream, tempFilePath: String?) { + let dest: OutputStream + let tempFilePath: String? + + // Build the form data in memory if the content is estimated to be less than 10 MB. Otherwise, use a temporary file. + let thresholdBytesForUsingTmpFile = 10_000_000 + let estimatedFormDataBytes = reduce(0) { $0 + $1.bytes } + if forceWriteToFile || estimatedFormDataBytes > thresholdBytesForUsingTmpFile { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).path + guard let stream = OutputStream(toFileAtPath: tempFile, append: false) else { + throw MultipartFormError.inaccessbileFile(path: tempFile) + } + dest = stream + tempFilePath = tempFile + } else { + dest = OutputStream.toMemory() + tempFilePath = nil + } + + return (dest, tempFilePath) + } + + func multipartFormDataStream(boundary: String, forceWriteToFile: Bool = false) throws -> Either { + guard !isEmpty else { + return .left(Data()) + } + + let (dest, tempFilePath) = try multipartFormDestination(forceWriteToFile: forceWriteToFile) + + // Build the form content + do { + dest.open() + defer { dest.close() } + + writeMultipartFormData(destination: dest, boundary: boundary) + } + + // Return the result as `InputStream` + if let tempFilePath { + return .right(URL(fileURLWithPath: tempFilePath)) + } + + if let data = dest.property(forKey: .dataWrittenToMemoryStreamKey) as? Data { + return .left(data) + } + + throw MultipartFormError.impossible + } + + private func writeMultipartFormData(destination dest: OutputStream, boundary: String) { + for field in self { + dest.writeMultipartForm(boundary: boundary, isEnd: false) + + // Write headers + var disposition = ["form-data", "name=\"\(field.name)\""] + if let filename = field.filename { + disposition += ["filename=\"\(filename)\""] + } + dest.writeMultipartFormHeader(name: "Content-Disposition", value: disposition.joined(separator: "; ")) + + if let mimeType = field.mimeType { + dest.writeMultipartFormHeader(name: "Content-Type", value: mimeType) + } + + // Write a linebreak between header and content + dest.writeMultipartFormLineBreak() + + // Write content + field.inputStream.open() + defer { + field.inputStream.close() + } + let maxLength = 1024 + var buffer = [UInt8](repeating: 0, count: maxLength) + while field.inputStream.hasBytesAvailable { + let bytes = field.inputStream.read(&buffer, maxLength: maxLength) + dest.write(data: Data(bytesNoCopy: &buffer, count: bytes, deallocator: .none)) + } + + dest.writeMultipartFormLineBreak() + } + + dest.writeMultipartForm(boundary: boundary, isEnd: true) + } +} + +private let multipartFormDataLineBreak = "\r\n" +private extension OutputStream { + func write(data: Data) { + let count = data.count + guard count > 0 else { return } + + _ = data.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in + write(ptr.bindMemory(to: Int8.self).baseAddress!, maxLength: count) + } + } + + func writeMultipartForm(lineContent: String) { + write(data: "\(lineContent)\(multipartFormDataLineBreak)".data(using: .utf8)!) + } + + func writeMultipartFormLineBreak() { + write(data: multipartFormDataLineBreak.data(using: .utf8)!) + } + + func writeMultipartFormHeader(name: String, value: String) { + writeMultipartForm(lineContent: "\(name): \(value)") + } + + func writeMultipartForm(boundary: String, isEnd: Bool) { + if isEnd { + writeMultipartForm(lineContent: "--\(boundary)--") + } else { + writeMultipartForm(lineContent: "--\(boundary)") + } + } +} diff --git a/WordPressKit/Sources/CoreAPI/NSDate+WordPressCom.swift b/WordPressKit/Sources/CoreAPI/NSDate+WordPressCom.swift new file mode 100644 index 000000000000..2170399c34d5 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/NSDate+WordPressCom.swift @@ -0,0 +1,30 @@ +import Foundation + +// This `NSDate` extension wraps the `Date` implementation. +// +// It's done in two types because we cannot expose the `Date` methods to Objective-C, since `Date` is not a class: +// +// `@objc can only be used with members of classes, @objc protocols, and concrete extensions of classes` +extension NSDate { + + /// Parses a date string + /// + /// Dates in the format specified in http://www.w3.org/TR/NOTE-datetime should be OK. + /// The kind of dates returned by the REST API should match that format, even if the doc promises ISO 8601. + /// + /// Parsing the full ISO 8601, or even RFC 3339 is more complex than this, and makes no sense right now. + /// + /// - SeeAlso: [WordPress.com REST API docs](https://developer.wordpress.com/docs/api/) + /// - Warning: This method doesn't support fractional seconds or dates with leap seconds (23:59:60 turns into 23:59:00) + // + // Needs to be `public` because of the usages in the Objective-C code. + @objc(dateWithWordPressComJSONString:) + public static func with(wordPressComJSONString jsonString: String) -> Date? { + Date.with(wordPressComJSONString: jsonString) + } + + @objc(WordPressComJSONString) + public func wordPressComJSONString() -> String { + (self as Date).wordPressComJSONString + } +} diff --git a/WordPressKit/Sources/CoreAPI/NonceRetrieval.swift b/WordPressKit/Sources/CoreAPI/NonceRetrieval.swift new file mode 100644 index 000000000000..b9e85a0c431a --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/NonceRetrieval.swift @@ -0,0 +1,96 @@ +import Foundation +import WordPressShared + +enum NonceRetrievalMethod { + case newPostScrap + case ajaxNonceRequest + + func retrieveNonce(username: String, password: Secret, loginURL: URL, adminURL: URL, using urlSession: URLSession) async -> String? { + guard let webpageThatContainsNonce = buildURL(base: adminURL) else { return nil } + + // First, make a request to the URL to grab REST API nonce. The HTTP request is very likely to pass, because + // when this method is called, user should have already authenticated and their site's cookies are already in + // the `urlSession. + if let found = await nonce(from: webpageThatContainsNonce, using: urlSession) { + return found + } + + // If the above request failed, then make a login request, which redirects to the webpage that contains + // REST API nonce. + let loginThenRedirect = HTTPRequestBuilder(url: loginURL) + .method(.post) + .body(form: [ + "log": username, + "pwd": password.secretValue, + "rememberme": "true", + "redirect_to": webpageThatContainsNonce.absoluteString + ]) + + return await nonce(from: loginThenRedirect, using: urlSession) + } + + private func buildURL(base: URL) -> URL? { + switch self { + case .newPostScrap: + return URL(string: "post-new.php", relativeTo: base) + case .ajaxNonceRequest: + return URL(string: "admin-ajax.php?action=rest-nonce", relativeTo: base) + } + } + + private func retrieveNonce(from html: String) -> String? { + switch self { + case .newPostScrap: + return scrapNonceFromNewPost(html: html) + case .ajaxNonceRequest: + return readNonceFromAjaxAction(html: html) + } + } + + private func scrapNonceFromNewPost(html: String) -> String? { + guard let regex = try? NSRegularExpression(pattern: "apiFetch.createNonceMiddleware\\(\\s*['\"](?\\w+)['\"]\\s*\\)", options: []), + let match = regex.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.count)) else { + return nil + } + let nsrange = match.range(withName: "nonce") + let nonce = Range(nsrange, in: html) + .map({ html[$0] }) + .map( String.init ) + + return nonce + } + + private func readNonceFromAjaxAction(html: String) -> String? { + guard !html.isEmpty, + html.allSatisfy({ $0.isNumber || $0.isLetter }) + else { + return nil + } + + return html + } +} + +private extension NonceRetrievalMethod { + + func nonce(from url: URL, using urlSession: URLSession) async -> String? { + await nonce(from: HTTPRequestBuilder(url: url), using: urlSession) + } + + func nonce(from builder: HTTPRequestBuilder, using urlSession: URLSession) async -> String? { + guard let request = try? builder.build() else { return nil } + + guard let (data, response) = try? await urlSession.data(for: request), + let httpResponse = response as? HTTPURLResponse + else { + return nil + } + + guard 200...299 ~= httpResponse.statusCode, let content = HTTPAPIResponse(response: httpResponse, body: data).bodyText else { + return nil + } + + return retrieveNonce(from: content) + } + +} diff --git a/WordPressKit/Sources/CoreAPI/Result+Callback.swift b/WordPressKit/Sources/CoreAPI/Result+Callback.swift new file mode 100644 index 000000000000..f34fcae8be2b --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/Result+Callback.swift @@ -0,0 +1,19 @@ +public extension Swift.Result { + + // Notice there are no explicit unit tests for this utility because it is implicitly tested via the consuming code's tests. + func execute(onSuccess: (Success) -> Void, onFailure: (Failure) -> Void) { + switch self { + case .success(let value): onSuccess(value) + case .failure(let error): onFailure(error) + } + } + + func execute(_ completion: (Self) -> Void) { + completion(self) + } + + func eraseToError() -> Result { + mapError { $0 } + } + +} diff --git a/WordPressKit/Sources/CoreAPI/SocialLogin2FANonceInfo.swift b/WordPressKit/Sources/CoreAPI/SocialLogin2FANonceInfo.swift new file mode 100644 index 000000000000..6f3e508d1ecc --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/SocialLogin2FANonceInfo.swift @@ -0,0 +1,56 @@ +import Foundation + +@objc +/// This type is not only used for social logins, but we have not renamed it to maintain compatibility. +/// +public class SocialLogin2FANonceInfo: NSObject { + @objc public var nonceSMS = "" + @objc public var nonceWebauthn = "" + @objc var nonceBackup = "" + @objc var nonceAuthenticator = "" + @objc var supportedAuthTypes = [String]() // backup|authenticator|sms|webauthn + @objc var notificationSent = "" // none|sms + @objc var phoneNumber = "" // The last two digits of the phone number to which an SMS was sent. + + private enum Constants { + static let lastUsedPlaceholder = "last_used_placeholder" + } + + /// These constants match the server-side authentication code + public enum TwoFactorTypeLengths: Int { + case authenticator = 6 + case sms = 7 + case backup = 8 + } + + public func authTypeAndNonce(for code: String) -> (String, String) { + let typeNoncePair: (String, String) + switch code.count { + case TwoFactorTypeLengths.sms.rawValue: + typeNoncePair = ("sms", nonceSMS) + nonceSMS = Constants.lastUsedPlaceholder + case TwoFactorTypeLengths.backup.rawValue: + typeNoncePair = ("backup", nonceBackup) + nonceBackup = Constants.lastUsedPlaceholder + case TwoFactorTypeLengths.authenticator.rawValue: + fallthrough + default: + typeNoncePair = ("authenticator", nonceAuthenticator) + nonceAuthenticator = Constants.lastUsedPlaceholder + } + return typeNoncePair + } + + @objc public func updateNonce(with newNonce: String) { + switch Constants.lastUsedPlaceholder { + case nonceSMS: + nonceSMS = newNonce + case nonceBackup: + nonceBackup = newNonce + case nonceAuthenticator: + fallthrough + default: + nonceAuthenticator = newNonce + } + } +} diff --git a/WordPressKit/Sources/CoreAPI/StringEncoding+IANA.swift b/WordPressKit/Sources/CoreAPI/StringEncoding+IANA.swift new file mode 100644 index 000000000000..c4d92d7efa69 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/StringEncoding+IANA.swift @@ -0,0 +1,44 @@ +import Foundation + +extension String.Encoding { + /// See: https://www.iana.org/assignments/character-sets/character-sets.xhtml + init?(ianaCharsetName: String) { + let encoding: CFStringEncoding = CFStringConvertIANACharSetNameToEncoding(ianaCharsetName as CFString) + guard encoding != kCFStringEncodingInvalidId, + let builtInEncoding = CFStringBuiltInEncodings(rawValue: encoding) + else { + return nil + } + + switch builtInEncoding { + case .macRoman: + self = .macOSRoman + case .windowsLatin1: + self = .windowsCP1252 + case .isoLatin1: + self = .isoLatin1 + case .nextStepLatin: + self = .nextstep + case .ASCII: + self = .ascii + case .unicode: + self = .unicode + case .UTF8: + self = .utf8 + case .nonLossyASCII: + self = .nonLossyASCII + case .UTF16BE: + self = .utf16BigEndian + case .UTF16LE: + self = .utf16LittleEndian + case .UTF32: + self = .utf32 + case .UTF32BE: + self = .utf32BigEndian + case .UTF32LE: + self = .utf32LittleEndian + @unknown default: + return nil + } + } +} diff --git a/WordPressKit/Sources/CoreAPI/WebauthChallengeInfo.swift b/WordPressKit/Sources/CoreAPI/WebauthChallengeInfo.swift new file mode 100644 index 000000000000..a07da3a489bf --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WebauthChallengeInfo.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Type that represents the Webauthn challenge info return by Wordpress.com +/// +@objc public class WebauthnChallengeInfo: NSObject { + /// Challenge to be signed. + /// + @objc public var challenge = "" + + /// The website this request is for + /// + @objc public var rpID = "" + + /// Nonce required by Wordpress.com to verify the signed challenge + /// + @objc public var twoStepNonce = "" + + /// Allowed credential IDs. + /// + @objc public var allowedCredentialIDs: [String] = [] + + init(challenge: String, rpID: String, twoStepNonce: String, allowedCredentialIDs: [String]) { + self.challenge = challenge + self.rpID = rpID + self.twoStepNonce = twoStepNonce + self.allowedCredentialIDs = allowedCredentialIDs + } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressAPIError+NSErrorBridge.swift b/WordPressKit/Sources/CoreAPI/WordPressAPIError+NSErrorBridge.swift new file mode 100644 index 000000000000..594c3e3e86b2 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressAPIError+NSErrorBridge.swift @@ -0,0 +1,116 @@ +import Foundation +#if SWIFT_PACKAGE +import APIInterface +#endif + +/// Custom `NSError` bridge implementation. +/// +/// The implementation ensures `NSError` instances that are casted from `WordPressAPIError.endpointError` +/// are the same as those instances that are casted directly from the underlying `EndpointError` instances. +/// +/// In theory, we should not need to implement this bridging, because we should never cast errors to `NSError` +/// instances in any error handling code. But since there are still Objective-C callers, providing this custom bridging +/// implementation comes in handy for those cases. See the `WordPressComRestApiEndpointError` extension below. +extension WordPressAPIError: CustomNSError { + + public static var errorDomain: String { + (EndpointError.self as? CustomNSError.Type)?.errorDomain + ?? String(describing: Self.self) + } + + public var errorCode: Int { + switch self { + case let .endpointError(endpointError): + return (endpointError as NSError).code + // Use negative values for other cases to reduce chances of collision with `EndpointError`. + case .requestEncodingFailure: + return -100000 + case .connection: + return -100001 + case .unacceptableStatusCode: + return -100002 + case .unparsableResponse: + return -100003 + case .unknown: + return -100004 + } + } + + public var errorUserInfo: [String: Any] { + switch self { + case let .endpointError(endpointError): + return (endpointError as NSError).userInfo + case .connection(let error): + return [NSUnderlyingErrorKey: error] + case .requestEncodingFailure, .unacceptableStatusCode, .unparsableResponse, + .unknown: + return [:] + } + } +} + +// MARK: - Bridge WordPressComRestApiEndpointError to NSError + +/// A custom NSError bridge implementation to ensure `NSError` instances converted from `WordPressComRestApiEndpointError` +/// are the same as the ones converted from their underlying error (the `code: WordPressComRestApiError` property in +/// `WordPressComRestApiEndpointError`). +/// +/// Along with `WordPressAPIError`'s conformance to `CustomNSError`, the three `NSError` instances below have the +/// same domain and code. +/// +/// ``` +/// let error: WordPressComRestApiError = // ... +/// let newError: WordPressComRestApiEndpointError = .init(code: error) +/// let apiError: WordPressAPIError = .endpointError(newError) +/// +/// // Following `NSError` instance have the same domain and code. +/// let errorNSError = error as NSError +/// let newErrorNSError = newError as NSError +/// let apiErrorNSError = apiError as NSError +/// ``` +/// +/// ## Why implementing this custom NSError brdige? +/// +/// `WordPressComRestApi` returns `NSError` instances to their callers. Since `WordPressComRestApi` is used in many +/// Objective-C file, we can't change the API to return an `Error` type that's Swift-only (i.e. `WordPressAPIError`). +/// If the day where there are no Objective-C callers finally comes, we definitely should stop returning `NSError` and +/// start using a concrete error type instead. But for now, we have to provide backwards compatiblity to those +/// Objective-C code while using `WordPressAPIError` internally in `WordPressComRestApi`. +/// +/// The `NSError` instances returned by `WordPressComRestApi` is one of the following: +/// - `WordPressComRestApiError` enum cases that are directly converted to `NSError` +/// - `NSError` instances that have domain and code from `WordPressComRestApiError`, with additional `userInfo` (error +/// code, message, etc). +/// - Error instances returned by Alamofire 4: `AFError`, or maybe other errors. +/// +/// Alamofire will be removed from this library, there is no point (also not possible) in providing backwards +/// compatiblity to `AFError`. That means, we need to make sure the `NSError` instances that are converted from +/// `WordPressAPIError` have the same error domain and code as the underlying `WordPressComRestApiError` enum. +/// And in cases where additional user info was provided, they need to be carried over to the `NSError` instances. +extension WordPressComRestApiEndpointError: CustomNSError { + + public static let errorDomain = WordPressComRestApiErrorDomain + + public var errorCode: Int { + code.rawValue + } + + public var errorUserInfo: [String: Any] { + var userInfo = additionalUserInfo ?? [:] + + if let code = apiErrorCode { + userInfo[WordPressComRestApi.ErrorKeyErrorCode] = code + } + if let message = apiErrorMessage { + userInfo[WordPressComRestApi.ErrorKeyErrorMessage] = message + userInfo[NSLocalizedDescriptionKey] = message + } + if let data = apiErrorData { + userInfo[WordPressComRestApi.ErrorKeyErrorData] = data + } + + return userInfo + + } + +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressAPIError.swift b/WordPressKit/Sources/CoreAPI/WordPressAPIError.swift new file mode 100644 index 000000000000..a4680b208d3b --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressAPIError.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum WordPressAPIError: Error where EndpointError: LocalizedError { + static var unknownErrorMessage: String { + NSLocalizedString( + "wordpress-api.error.unknown", + value: "Something went wrong, please try again later.", + comment: "Error message that describes an unknown error had occured" + ) + } + + /// Can't encode the request arguments into a valid HTTP request. This is a programming error. + case requestEncodingFailure(underlyingError: Error) + /// Error occured in the HTTP connection. + case connection(URLError) + /// The API call returned an error result. For example, an OAuth endpoint may return an 'incorrect username or password' error, an upload media endpoint may return an 'unsupported media type' error. + case endpointError(EndpointError) + /// The API call returned an status code that's unacceptable to the endpoint. + case unacceptableStatusCode(response: HTTPURLResponse, body: Data) + /// The API call returned an HTTP response that WordPressKit can't parse. Receiving this error could be an indicator that there is an error response that's not handled properly by WordPressKit. + case unparsableResponse(response: HTTPURLResponse?, body: Data?, underlyingError: Error) + /// Other error occured. + case unknown(underlyingError: Error) + + static func unparsableResponse(response: HTTPURLResponse?, body: Data?) -> Self { + return WordPressAPIError.unparsableResponse(response: response, body: body, underlyingError: URLError(.cannotParseResponse)) + } + + var response: HTTPURLResponse? { + switch self { + case .requestEncodingFailure, .connection, .unknown: + return nil + case let .endpointError(error): + return (error as? HTTPURLResponseProviding)?.httpResponse + case .unacceptableStatusCode(let response, _): + return response + case .unparsableResponse(let response, _, _): + return response + } + } +} + +extension WordPressAPIError: LocalizedError { + + public var errorDescription: String? { + // Considering `WordPressAPIError` is the error that's surfaced from this library to the apps, its instanes + // may be displayed on UI directly. To prevent Swift's default error message (i.e. "This operation can't be + // completed. (code=...)") from being displayed, we need to make sure this implementation + // always returns a non-nil value. + let localizedErrorMessage: String + switch self { + case .requestEncodingFailure, .unparsableResponse, .unacceptableStatusCode: + // These are usually programming errors. + localizedErrorMessage = Self.unknownErrorMessage + case let .endpointError(error): + localizedErrorMessage = error.errorDescription ?? Self.unknownErrorMessage + case let .connection(error): + localizedErrorMessage = error.localizedDescription + case let .unknown(underlyingError): + if let msg = (underlyingError as? LocalizedError)?.errorDescription { + localizedErrorMessage = msg + } else { + localizedErrorMessage = Self.unknownErrorMessage + } + } + return localizedErrorMessage + } + +} + +protocol HTTPURLResponseProviding { + var httpResponse: HTTPURLResponse? { get } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressComOAuthClient.swift b/WordPressKit/Sources/CoreAPI/WordPressComOAuthClient.swift new file mode 100644 index 000000000000..3060708b86e6 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressComOAuthClient.swift @@ -0,0 +1,812 @@ +import Foundation + +public typealias WordPressComOAuthError = WordPressAPIError + +public extension WordPressComOAuthError { + var authenticationFailureKind: AuthenticationFailure.Kind? { + if case let .endpointError(failure) = self { + return failure.kind + } + return nil + } +} + +public struct AuthenticationFailure: LocalizedError { + private static let errorsMap: [String: AuthenticationFailure.Kind] = [ + "invalid_client": .invalidClient, + "unsupported_grant_type": .unsupportedGrantType, + "invalid_request": .invalidRequest, + "needs_2fa": .needsMultifactorCode, + "invalid_otp": .invalidOneTimePassword, + "user_exists": .socialLoginExistingUserUnconnected, + "invalid_two_step_code": .invalidTwoStepCode, + "unknown_user": .unknownUser + ] + + public enum Kind { + /// client_id is missing or wrong, it shouldn't happen + case invalidClient + /// client_id doesn't support password grants + case unsupportedGrantType + /// A required field is missing/malformed + case invalidRequest + /// Multifactor Authentication code is required + case needsMultifactorCode + /// Supplied one time password is incorrect + case invalidOneTimePassword + /// Returned by the social login endpoint if a wpcom user is found, but not connected to a social service. + case socialLoginExistingUserUnconnected + /// Supplied MFA code is incorrect + case invalidTwoStepCode + case unknownUser + case unknown + } + + public var kind: Kind + public var localizedErrorMessage: String? + public var newNonce: String? + public var originalErrorJSON: [String: AnyObject] + + init(response: HTTPURLResponse, body: Data) throws { + guard [400, 409, 403].contains(response.statusCode) else { + throw URLError(.cannotParseResponse) + } + + let responseObject = try JSONSerialization.jsonObject(with: body, options: .allowFragments) + + guard let responseDictionary = responseObject as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + self.init(apiJSONResponse: responseDictionary) + } + + init(apiJSONResponse responseDict: [String: AnyObject]) { + originalErrorJSON = responseDict + + // there's either a data object, or an error. + if let errorStr = responseDict["error"] as? String { + kind = Self.errorsMap[errorStr] ?? .unknown + localizedErrorMessage = responseDict["error_description"] as? String + } else if let data = responseDict["data"] as? [String: AnyObject], + let errors = data["errors"] as? NSArray, + let err = errors.firstObject as? [String: AnyObject] { + let errorCode = err["code"] as? String ?? "" + kind = Self.errorsMap[errorCode] ?? .unknown + localizedErrorMessage = err["message"] as? String + newNonce = data["two_step_nonce"] as? String + } else { + kind = .unknown + } + } +} + +/// `WordPressComOAuthClient` encapsulates the pattern of authenticating against WordPress.com OAuth2 service. +/// +/// Right now it requires a special client id and secret, so this probably won't work for you +/// @see https://developer.wordpress.com/docs/oauth2/ +/// +public final class WordPressComOAuthClient: NSObject { + + @objc public static let WordPressComOAuthDefaultBaseURL = URL(string: "https://wordpress.com")! + @objc public static let WordPressComOAuthDefaultApiBaseURL = URL(string: "https://public-api.wordpress.com")! + + @objc public static let WordPressComSocialLoginEndpointVersion = 1.0 + + private let clientID: String + private let secret: String + + private let wordPressComBaseURL: URL + private let wordPressComApiBaseURL: URL + + // Question: Is it necessary to use these many URLSession instances? + private let oauth2Session = WordPressComOAuthClient.urlSession() + private let webAuthnSession = WordPressComOAuthClient.urlSession() + private let socialSession = WordPressComOAuthClient.urlSession() + private let social2FASession = WordPressComOAuthClient.urlSession() + private let socialNewSMS2FASession = WordPressComOAuthClient.urlSession() + + private class func urlSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpAdditionalHeaders = ["Accept": "application/json"] + return URLSession(configuration: configuration) + } + + /// Creates a WordPresComOAuthClient initialized with the clientID and secrets provided + /// + @objc public class func client(clientID: String, secret: String) -> WordPressComOAuthClient { + WordPressComOAuthClient(clientID: clientID, secret: secret) + } + + /// Creates a WordPresComOAuthClient initialized with the clientID, secret and base urls provided + /// + @objc public class func client(clientID: String, + secret: String, + wordPressComBaseURL: URL, + wordPressComApiBaseURL: URL) -> WordPressComOAuthClient { + WordPressComOAuthClient( + clientID: clientID, + secret: secret, + wordPressComBaseURL: wordPressComBaseURL, + wordPressComApiBaseURL: wordPressComApiBaseURL + ) + } + + /// Creates a WordPressComOAuthClient using the defined clientID and secret + /// + /// - Parameters: + /// - clientID: the app oauth clientID + /// - secret: the app secret + /// - wordPressComBaseURL: The base url to use for WordPress.com requests. Defaults to https://wordpress.com + /// - wordPressComApiBaseURL: The base url to use for WordPress.com API requests. Defaults to https://public-api-wordpress.com + /// + @objc public init(clientID: String, + secret: String, + wordPressComBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultBaseURL, + wordPressComApiBaseURL: URL = WordPressComOAuthClient.WordPressComOAuthDefaultApiBaseURL) { + self.clientID = clientID + self.secret = secret + self.wordPressComBaseURL = wordPressComBaseURL + self.wordPressComApiBaseURL = wordPressComApiBaseURL + } + + public enum AuthenticationResult { + case authenticated(token: String) + case needsMultiFactor(userID: Int, nonceInfo: SocialLogin2FANonceInfo) + } + + /// Authenticates on WordPress.com using the OAuth endpoints. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil + public func authenticate( + username: String, + password: String, + multifactorCode: String? + ) async -> WordPressAPIResult { + var form = [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "with_auth_types": "true" + ] + + if let multifactorCode, !multifactorCode.isEmpty { + form["wpcom_otp"] = multifactorCode + } + + let builder = tokenRequestBuilder().body(form: form) + return await oauth2Session + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try JSONSerialization.jsonObject(with: response.body) + + // WPKitLogVerbose("Received OAuth2 response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") + + guard let responseDictionary = responseObject as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + // If we found an access_token, we are authed. + if let authToken = responseDictionary["access_token"] as? String { + return .authenticated(token: authToken) + } + + // If there is no access token, check for a security key nonce + guard let responseData = responseDictionary["data"] as? [String: AnyObject], + let userID = responseData["user_id"] as? Int, + let _ = responseData["two_step_nonce_webauthn"] else { + throw URLError(.cannotParseResponse) + } + + let nonceInfo = self.extractNonceInfo(data: responseData) + + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + } + } + + /// Authenticates on WordPress.com using the OAuth endpoints. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - multifactorCode: Multifactor Authentication One-Time-Password. If not needed, can be nil + /// - needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + username: String, + password: String, + multifactorCode: String?, + needsMultifactor: @escaping ((_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void), + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + let result = await authenticate(username: username, password: password, multifactorCode: multifactorCode) + switch result { + case let .success(.authenticated(token)): + success(token) + case let .success(.needsMultiFactor(userID, nonceInfo)): + needsMultifactor(userID, nonceInfo) + case let .failure(error): + failure(error) + } + } + } + + /// Requests a One Time Code, to be sent via SMS. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestOneTimeCode(username: String, password: String) async -> WordPressAPIResult { + let builder = tokenRequestBuilder() + .body(form: [ + "username": username, + "password": password, + "grant_type": "password", + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "wpcom_resend_otp": "true" + ]) + return await oauth2Session + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { _ in () } + } + + /// Requests a One Time Code, to be sent via SMS. + /// + /// - Parameters: + /// - username: the account's username. + /// - password: the account's password. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestOneTimeCode( + username: String, + password: String, + success: @escaping () -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await requestOneTimeCode(username: username, password: password) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// Request a new SMS code to be sent during social login + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - nonce: The nonce from a social login attempt. + public func requestSocial2FACode( + userID: Int, + nonce: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .sendOTPViaSMS) + .body( + form: [ + "user_id": "\(userID)", + "two_step_nonce": nonce, + "client_id": clientID, + "client_secret": secret, + "wpcom_supports_2fa": "true", + "wpcom_resend_otp": "true" + ] + ) + + return await socialNewSMS2FASession + .perform(request: builder, errorType: AuthenticationFailure.self) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response -> String in + guard let responseObject = try? JSONSerialization.jsonObject(with: response.body), + let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + return self.extractNonceInfo(data: responseData).nonceSMS + } + .flatMapError { error in + if case let .endpointError(authenticationFailure) = error, let newNonce = authenticationFailure.newNonce { + return .success(newNonce) + } + return .failure(error) + } + } + + /// Request a new SMS code to be sent during social login + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - nonce: The nonce from a social login attempt. + /// - success: block to be called if authentication was successful. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestSocial2FACode( + userID: Int, + nonce: String, + success: @escaping (_ newNonce: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError, _ newNonce: String?) -> Void + ) { + Task { @MainActor in + let result = await requestSocial2FACode(userID: userID, nonce: nonce) + switch result { + case let .success(newNonce): + success(newNonce) + case let .failure(error): + // TODO: Remove the `newNonce` argument? + failure(error, nil) + } + } + } + + public enum SocialAuthenticationResult { + case authenticated(token: String) + case needsMultiFactor(userID: Int, nonceInfo: SocialLogin2FANonceInfo) + case existingUserNeedsConnection(email: String) + } + + /// Authenticate on WordPress.com with a social service's ID token. + /// + /// - Parameters: + /// - token: A social ID token obtained from a supported social service. + /// - service: The social service type (ex: "google" or "apple"). + public func authenticate( + socialIDToken token: String, + service: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .authenticate) + .body( + form: [ + "client_id": clientID, + "client_secret": secret, + "service": service, + "get_bearer_token": "true", + "id_token": token + ] + ) + + return await socialSession + .perform(request: builder, errorType: AuthenticationFailure.self) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + // WPKitLogVerbose("Received Social Login Oauth response.") + + // Make sure we received expected data. + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject] else { + throw URLError(.cannotParseResponse) + } + + // Check for a bearer token. If one is found then we're authed. + if let authToken = responseData["bearer_token"] as? String { + return .authenticated(token: authToken) + } + + // If there is no bearer token, check for 2fa enabled. + guard let userID = responseData["user_id"] as? Int, + let _ = responseData["two_step_nonce_backup"] else { + throw URLError(.cannotParseResponse) + } + + let nonceInfo = self.extractNonceInfo(data: responseData) + return .needsMultiFactor(userID: userID, nonceInfo: nonceInfo) + } + .flatMapError { error in + // Inspect the error and handle the case of an existing user. + if case let .endpointError(authenticationFailure) = error, authenticationFailure.kind == .socialLoginExistingUserUnconnected { + // Get the responseObject from the userInfo dict. + // Extract the email address for the callback. + let responseDict = authenticationFailure.originalErrorJSON + if let data = responseDict["data"] as? [String: AnyObject], + let email = data["email"] as? String { + return .success(.existingUserNeedsConnection(email: email)) + } + } + return .failure(error) + } + } + + /// Authenticate on WordPress.com with a social service's ID token. + /// + /// - Parameters: + /// - token: A social ID token obtained from a supported social service. + /// - service: The social service type (ex: "google" or "apple"). + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - needsMultifactor: block to be called if a 2fa token is needed to complete the auth process. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + socialIDToken token: String, + service: String, + success: @escaping (_ authToken: String?) -> Void, + needsMultifactor: @escaping (_ userID: Int, _ nonceInfo: SocialLogin2FANonceInfo) -> Void, + existingUserNeedsConnection: @escaping (_ email: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + let result = await self.authenticate(socialIDToken: token, service: service) + switch result { + case let .success(.authenticated(token)): + success(token) + case let .success(.needsMultiFactor(userID, nonceInfo)): + needsMultifactor(userID, nonceInfo) + case let .success(.existingUserNeedsConnection(email)): + existingUserNeedsConnection(email) + case let .failure(error): + failure(error) + } + } + } + + /// Request a security key challenge from WordPress.com to be signed by the client. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a log in attempt. + public func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String + ) async -> WordPressAPIResult { + let builder = webAuthnRequestBuilder(action: .requestChallenge) + .body(form: [ + "user_id": "\(userID)", + "client_id": clientID, + "client_secret": secret, + "auth_type": "webauthn", + "two_step_nonce": twoStepNonce, + ]) + return await webAuthnSession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + // Expect the parent data response object + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: Any], + let responseData = responseDictionary["data"] as? [String: Any] else { + throw URLError(.cannotParseResponse) + } + + // Expect the challenge info. + guard + let challenge = responseData["challenge"] as? String, + let nonce = responseData["two_step_nonce"] as? String, + let rpID = responseData["rpId"] as? String, + let allowCredentials = responseData["allowCredentials"] as? [[String: Any]] + else { + throw URLError(.cannotParseResponse) + } + + let allowedCredentialIDs = allowCredentials.compactMap { $0["id"] as? String } + return WebauthnChallengeInfo(challenge: challenge, rpID: rpID, twoStepNonce: nonce, allowedCredentialIDs: allowedCredentialIDs) + } + } + + /// Request a security key challenge from WordPress.com to be signed by the client. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a log in attempt. + /// - success: block to be called if authentication was successful. The challenge info is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func requestWebauthnChallenge( + userID: Int64, + twoStepNonce: String, + success: @escaping (_ challengeData: WebauthnChallengeInfo) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await requestWebauthnChallenge(userID: userID, twoStepNonce: twoStepNonce) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// Verifies a signed challenge with a security key on WordPress.com. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a request challenge attempt. + /// - credentialID: The id of the security key that signed the challenge. + /// - clientDataJson: Json returned by the passkey framework. + /// - authenticatorData: Authenticator Data from the security key. + /// - signature: Signature to verify. + /// - userHandle: User associated with the security key. + public func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data + ) async -> WordPressAPIResult { + let clientData: [String: AnyHashable] = [ + "id": credentialID.base64EncodedString(), + "rawId": credentialID.base64EncodedString(), + "type": "public-key", + "clientExtensionResults": Dictionary(), + "response": [ + "clientDataJSON": clientDataJson.base64EncodedString(), + "authenticatorData": authenticatorData.base64EncodedString(), + "signature": signature.base64EncodedString(), + "userHandle": userHandle.base64EncodedString(), + ] + ] + + let clientDataString: String + do { + let serializedClientData = try JSONSerialization.data(withJSONObject: clientData, options: .withoutEscapingSlashes) + guard let string = String(data: serializedClientData, encoding: .utf8) else { + throw URLError(.badURL) + } + clientDataString = string + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + let builder = webAuthnRequestBuilder(action: .authenticate) + .body(form: [ + "user_id": "\(userID)", + "client_id": clientID, + "client_secret": secret, + "auth_type": "webauthn", + "two_step_nonce": twoStepNonce, + "client_data": clientDataString, + "get_bearer_token": "true", + "create_2fa_cookies_only": "true", + ]) + + return await webAuthnSession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try? JSONSerialization.jsonObject(with: response.body) + guard let responseDictionary = responseObject as? [String: Any], + let successResponse = responseDictionary["success"] as? Bool, successResponse, + let responseData = responseDictionary["data"] as? [String: Any] else { + throw URLError(.cannotParseResponse) + } + + // Check for a bearer token. If one is found then we're authed. + guard let authToken = responseData["bearer_token"] as? String else { + throw URLError(.cannotParseResponse) + } + + return authToken + } + } + + /// Verifies a signed challenge with a security key on WordPress.com. + /// + /// - Parameters: + /// - userID: the wpcom userID + /// - twoStepNonce: The nonce returned from a request challenge attempt. + /// - credentialID: The id of the security key that signed the challenge. + /// - clientDataJson: Json returned by the passkey framework. + /// - authenticatorData: Authenticator Data from the security key. + /// - signature: Signature to verify. + /// - userHandle: User associated with the security key. + /// - success: block to be called if authentication was successful. The auth token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticateWebauthnSignature( + userID: Int64, + twoStepNonce: String, + credentialID: Data, + clientDataJson: Data, + authenticatorData: Data, + signature: Data, + userHandle: Data, + success: @escaping (_ authToken: String) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await authenticateWebauthnSignature( + userID: userID, + twoStepNonce: twoStepNonce, + credentialID: credentialID, + clientDataJson: clientDataJson, + authenticatorData: authenticatorData, + signature: signature, + userHandle: userHandle + ) + .execute(onSuccess: success, onFailure: failure) + } + } + + /// A helper method to get an instance of SocialLogin2FANonceInfo and populate + /// it with the supplied data. + /// + /// - Parameters: + /// - data: The dictionary to use to populate the instance. + /// + /// - Return: SocialLogin2FANonceInfo + /// + private func extractNonceInfo(data: [String: AnyObject]) -> SocialLogin2FANonceInfo { + let nonceInfo = SocialLogin2FANonceInfo() + + if let nonceAuthenticator = data["two_step_nonce_authenticator"] as? String { + nonceInfo.nonceAuthenticator = nonceAuthenticator + } + + // atm, used for requesting and verifying a security key. + if let nonceWebauthn = data["two_step_nonce_webauthn"] as? String { + nonceInfo.nonceWebauthn = nonceWebauthn + } + + // atm, the only use of the more vague "two_step_nonce" key is when requesting a new SMS code + if let nonce = data["two_step_nonce"] as? String { + nonceInfo.nonceSMS = nonce + } + + if let nonce = data["two_step_nonce_sms"] as? String { + nonceInfo.nonceSMS = nonce + } + + if let nonce = data["two_step_nonce_backup"] as? String { + nonceInfo.nonceBackup = nonce + } + + if let notification = data["two_step_notification_sent"] as? String { + nonceInfo.notificationSent = notification + } + + if let authTypes = data["two_step_supported_auth_types"] as? [String] { + nonceInfo.supportedAuthTypes = authTypes + } + + if let phone = data["phone_number"] as? String { + nonceInfo.phoneNumber = phone + } + + return nonceInfo + } + + /// Completes a social login that has 2fa enabled. + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - authType: The type of 2fa authentication being used. (sms|backup|authenticator) + /// - twoStepCode: The user's 2fa code. + /// - twoStepNonce: The nonce returned from a social login attempt. + public func authenticate( + socialLoginUserID userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String + ) async -> WordPressAPIResult { + let builder = socialSignInRequestBuilder(action: .authenticateWith2FA) + .body(form: [ + "user_id": "\(userID)", + "auth_type": authType, + "two_step_code": twoStepCode, + "two_step_nonce": twoStepNonce, + "get_bearer_token": "true", + "client_id": clientID, + "client_secret": secret + ]) + return await social2FASession + .perform(request: builder) + .mapUnacceptableStatusCodeError(AuthenticationFailure.init(response:body:)) + .mapSuccess { response in + let responseObject = try JSONSerialization.jsonObject(with: response.body) + + // WPKitLogVerbose("Received Social Login Oauth response: \(self.cleanedUpResponseForLogging(responseObject as AnyObject? ?? "nil" as AnyObject))") + guard let responseDictionary = responseObject as? [String: AnyObject], + let responseData = responseDictionary["data"] as? [String: AnyObject], + let authToken = responseData["bearer_token"] as? String else { + throw URLError(.cannotParseResponse) + } + + return authToken + } + } + + /// Completes a social login that has 2fa enabled. + /// + /// - Parameters: + /// - userID: The wpcom user id. + /// - authType: The type of 2fa authentication being used. (sms|backup|authenticator) + /// - twoStepCode: The user's 2fa code. + /// - twoStepNonce: The nonce returned from a social login attempt. + /// - success: block to be called if authentication was successful. The OAuth2 token is passed as a parameter. + /// - failure: block to be called if authentication failed. The error object is passed as a parameter. + public func authenticate( + socialLoginUserID userID: Int, + authType: String, + twoStepCode: String, + twoStepNonce: String, + success: @escaping (_ authToken: String?) -> Void, + failure: @escaping (_ error: WordPressComOAuthError) -> Void + ) { + Task { @MainActor in + await authenticate( + socialLoginUserID: userID, + authType: authType, + twoStepCode: twoStepCode, + twoStepNonce: twoStepNonce + ) + .execute(onSuccess: success, onFailure: failure) + } + } + + private func cleanedUpResponseForLogging(_ response: AnyObject) -> AnyObject { + guard var responseDictionary = response as? [String: AnyObject] else { + return response + } + + // If the response is wrapped in a "data" field, clean up tokens inside it. + if var dataDictionary = responseDictionary["data"] as? [String: AnyObject] { + let keys = ["access_token", "bearer_token", "token_links"] + for key in keys { + if dataDictionary[key] != nil { + dataDictionary[key] = "*** REDACTED ***" as AnyObject? + } + } + + responseDictionary.updateValue(dataDictionary as AnyObject, forKey: "data") + + return responseDictionary as AnyObject + } + + let keys = ["access_token", "bearer_token"] + for key in keys { + if responseDictionary[key] != nil { + responseDictionary[key] = "*** REDACTED ***" as AnyObject? + } + } + + return responseDictionary as AnyObject + } + +} + +private extension WordPressComOAuthClient { + func tokenRequestBuilder() -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComApiBaseURL) + .method(.post) + .append(percentEncodedPath: "/oauth2/token") + } + + enum WebAuthnAction: String { + case requestChallenge = "webauthn-challenge-endpoint" + case authenticate = "webauthn-authentication-endpoint" + } + + func webAuthnRequestBuilder(action: WebAuthnAction) -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComBaseURL) + .method(.post) + .append(percentEncodedPath: "/wp-login.php") + .query(name: "action", value: action.rawValue) + } + + enum SocialSignInAction: String { + case sendOTPViaSMS = "send-sms-code-endpoint" + case authenticate = "social-login-endpoint" + case authenticateWith2FA = "two-step-authentication-endpoint" + + var queryItems: [URLQueryItem] { + var items = [URLQueryItem(name: "action", value: rawValue)] + if self == .authenticate || self == .authenticateWith2FA { + items.append(URLQueryItem(name: "version", value: "1.0")) + } + return items + } + } + + func socialSignInRequestBuilder(action: SocialSignInAction) -> HTTPRequestBuilder { + HTTPRequestBuilder(url: wordPressComBaseURL) + .method(.post) + .append(percentEncodedPath: "/wp-login.php") + .append(query: action.queryItems, override: true) + } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressComRestApi.swift b/WordPressKit/Sources/CoreAPI/WordPressComRestApi.swift new file mode 100644 index 000000000000..a8ebeb26267f --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressComRestApi.swift @@ -0,0 +1,676 @@ +#if SWIFT_PACKAGE +import APIInterface +#endif +import Foundation +import WordPressShared + +// MARK: - WordPressComRestApiError + +@available(*, deprecated, renamed: "WordPressComRestApiErrorCode", message: "`WordPressComRestApiError` is renamed to `WordPressRestApiErrorCode`, and no longer conforms to `Swift.Error`") +public typealias WordPressComRestApiError = WordPressComRestApiErrorCode + +/** + Error constants for the WordPress.com REST API + + - InvalidInput: The parameters sent to the server where invalid + - InvalidToken: The token provided was invalid + - AuthorizationRequired: Permission required to access resource + - UploadFailed: The upload failed + - RequestSerializationFailed: The serialization of the request failed + - Unknown: Unknow error happen + */ +@objc public enum WordPressComRestApiErrorCode: Int, CaseIterable { + case invalidInput + case invalidToken + case authorizationRequired + case uploadFailed + case requestSerializationFailed + case responseSerializationFailed + case tooManyRequests + case unknown + case preconditionFailure + case malformedURL + case invalidQuery +} + +public struct WordPressComRestApiEndpointError: Error { + public var code: WordPressComRestApiErrorCode + var response: HTTPURLResponse? + + public var apiErrorCode: String? + public var apiErrorMessage: String? + public var apiErrorData: AnyObject? + + var additionalUserInfo: [String: Any]? +} + +extension WordPressComRestApiEndpointError: LocalizedError { + public var errorDescription: String? { + apiErrorMessage + } +} + +extension WordPressComRestApiEndpointError: HTTPURLResponseProviding { + var httpResponse: HTTPURLResponse? { + response + } +} + +public enum ResponseType { + case json + case data +} + +// MARK: - WordPressComRestApi + +open class WordPressComRestApi: NSObject { + + /// Use `URLSession` directly (instead of Alamofire) to send API requests. + @available(*, deprecated, message: "This property is no longer being used because WordPressKit now sends all HTTP requests using `URLSession` directly.") + public static var useURLSession = true + + // MARK: Properties + + @objc public static let ErrorKeyErrorCode = "WordPressComRestApiErrorCodeKey" + @objc public static let ErrorKeyErrorMessage = "WordPressComRestApiErrorMessageKey" + @objc public static let ErrorKeyErrorData = "WordPressComRestApiErrorDataKey" + @objc public static let ErrorKeyErrorDataEmail = "email" + + @objc public static let LocaleKeyDefault = "locale" // locale is specified with this for v1 endpoints + @objc public static let LocaleKeyV2 = "_locale" // locale is prefixed with an underscore for v2 + + public typealias RequestEnqueuedBlock = (_ taskID: NSNumber) -> Void + public typealias SuccessResponseBlock = (_ responseObject: AnyObject, _ httpResponse: HTTPURLResponse?) -> Void + public typealias FailureReponseBlock = (_ error: NSError, _ httpResponse: HTTPURLResponse?) -> Void + public typealias APIResult = WordPressAPIResult, WordPressComRestApiEndpointError> + + @objc public static let apiBaseURL: URL = URL(string: "https://public-api.wordpress.com/")! + + @objc public static let defaultBackgroundSessionIdentifier = "org.wordpress.wpcomrestapi" + + private let oAuthToken: String? + + private let userAgent: String? + + @objc public let backgroundSessionIdentifier: String + + @objc public let sharedContainerIdentifier: String? + + private let backgroundUploads: Bool + + private let localeKey: String + + @objc public let baseURL: URL + + private var invalidTokenHandler: (() -> Void)? + + /** + Configure whether or not the user's preferred language locale should be appended. Defaults to true. + */ + @objc open var appendsPreferredLanguageLocale = true + + // MARK: WordPressComRestApi + + @objc convenience public init(oAuthToken: String? = nil, userAgent: String? = nil) { + self.init(oAuthToken: oAuthToken, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressComRestApi.defaultBackgroundSessionIdentifier) + } + + @objc convenience public init(oAuthToken: String? = nil, userAgent: String? = nil, baseURL: URL = WordPressComRestApi.apiBaseURL) { + self.init(oAuthToken: oAuthToken, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressComRestApi.defaultBackgroundSessionIdentifier, baseURL: baseURL) + } + + /// Creates a new API object to connect to the WordPress Rest API. + /// + /// - Parameters: + /// - oAuthToken: the oAuth token to be used for authentication. + /// - userAgent: the user agent to identify the client doing the connection. + /// - backgroundUploads: If this value is true the API object will use a background session to execute uploads requests when using the `multipartPOST` function. The default value is false. + /// - backgroundSessionIdentifier: The session identifier to use for the background session. This must be unique in the system. + /// - sharedContainerIdentifier: An optional string used when setting up background sessions for use in an app extension. Default is nil. + /// - localeKey: The key with which to specify locale in the parameters of a request. + /// - baseURL: The base url to use for API requests. Default is https://public-api.wordpress.com/ + /// + /// - Discussion: When backgroundUploads are activated any request done by the multipartPOST method will use background session. This background session is shared for all multipart + /// requests and the identifier used must be unique in the system, Apple recomends to use invert DNS base on your bundle ID. Keep in mind these requests will continue even + /// after the app is killed by the system and the system will retried them until they are done. If the background session is initiated from an app extension, you *must* provide a value + /// for the sharedContainerIdentifier. + /// + @objc public init(oAuthToken: String? = nil, userAgent: String? = nil, + backgroundUploads: Bool = false, + backgroundSessionIdentifier: String = WordPressComRestApi.defaultBackgroundSessionIdentifier, + sharedContainerIdentifier: String? = nil, + localeKey: String = WordPressComRestApi.LocaleKeyDefault, + baseURL: URL = WordPressComRestApi.apiBaseURL) { + self.oAuthToken = oAuthToken + self.userAgent = userAgent + self.backgroundUploads = backgroundUploads + self.backgroundSessionIdentifier = backgroundSessionIdentifier + self.sharedContainerIdentifier = sharedContainerIdentifier + self.localeKey = localeKey + self.baseURL = baseURL + + super.init() + } + + deinit { + for session in [urlSession, uploadURLSession] { + session.finishTasksAndInvalidate() + } + } + + /// Cancels all outgoing tasks asynchronously without invalidating the session. + public func cancelTasks() { + for session in [urlSession, uploadURLSession] { + session.getAllTasks { tasks in + tasks.forEach({ $0.cancel() }) + } + } + } + + /** + Cancels all ongoing taks and makes the session invalid so the object will not fullfil any more request + */ + @objc open func invalidateAndCancelTasks() { + for session in [urlSession, uploadURLSession] { + session.invalidateAndCancel() + } + } + + @objc func setInvalidTokenHandler(_ handler: @escaping () -> Void) { + invalidTokenHandler = handler + } + + // MARK: Network requests + + /** + Executes a GET request to the specified endpoint defined on URLString + + - parameter URLString: the url string to be added to the baseURL + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func GET(_ URLString: String, + parameters: [String: AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await self.perform(.get, URLString: URLString, parameters: parameters, fulfilling: progress) + + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + open func GETData(_ URLString: String, + parameters: [String: AnyObject]?, + completion: @escaping (Swift.Result<(Data, HTTPURLResponse?), Error>) -> Void) { + Task { @MainActor in + let result = await perform(.get, URLString: URLString, parameters: parameters, fulfilling: nil, decoder: { $0 }) + + completion( + result + .map { ($0.body, $0.response) } + .eraseToError() + ) + } + } + + /** + Executes a POST request to the specified endpoint defined on URLString + + - parameter URLString: the url string to be added to the baseURL + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the upload and to cancel the upload. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func POST(_ URLString: String, + parameters: [String: AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await self.perform(.post, URLString: URLString, parameters: parameters, fulfilling: progress) + + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + /** + Executes a multipart POST using the current serializer, the parameters defined and the fileParts defined in the request + This request will be streamed from disk, so it's ideally to be used for large media post uploads. + + - parameter URLString: the endpoint to connect + - parameter parameters: the parameters to use on the request + - parameter fileParts: the file parameters that are added to the multipart request + - parameter requestEnqueued: callback to be called when the fileparts are serialized and request is added to the background session. Defaults to nil + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a `Progerss` object that can be used to track the progress of the upload and to cancel the upload. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @nonobjc @discardableResult open func multipartPOST( + _ URLString: String, + parameters: [String: AnyObject]?, + fileParts: [FilePart], + requestEnqueued: RequestEnqueuedBlock? = nil, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock + ) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + + Task { @MainActor in + let result = await upload(URLString: URLString, parameters: parameters, fileParts: fileParts, requestEnqueued: requestEnqueued, fulfilling: progress) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + + return progress + } + + @objc open func hasCredentials() -> Bool { + guard let authToken = oAuthToken else { + return false + } + return !(authToken.isEmpty) + } + + override open var hash: Int { + return "\(String(describing: oAuthToken)),\(String(describing: userAgent))".hashValue + } + + func requestBuilder(URLString: String) throws -> HTTPRequestBuilder { + guard let url = URL(string: URLString, relativeTo: baseURL) else { + throw URLError(.badURL) + } + + var builder = HTTPRequestBuilder(url: url) + + if appendsPreferredLanguageLocale { + let preferredLanguageIdentifier = WordPressComLanguageDatabase().deviceLanguage.slug + builder = builder.query(defaults: [URLQueryItem(name: localeKey, value: preferredLanguageIdentifier)]) + } + + return builder + } + + @objc public func temporaryFileURL(withExtension fileExtension: String) -> URL { + assert(!fileExtension.isEmpty, "file Extension cannot be empty") + let fileName = "\(ProcessInfo.processInfo.globallyUniqueString)_file.\(fileExtension)" + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(fileName) + return fileURL + } + + // MARK: - Async + + private lazy var urlSession: URLSession = { + URLSession(configuration: sessionConfiguration(background: false)) + }() + + private lazy var uploadURLSession: URLSession = { + let configuration = sessionConfiguration(background: backgroundUploads) + configuration.sharedContainerIdentifier = self.sharedContainerIdentifier + if configuration.identifier != nil { + return URLSession.backgroundSession(configuration: configuration) + } else { + return URLSession(configuration: configuration) + } + }() + + private func sessionConfiguration(background: Bool) -> URLSessionConfiguration { + let configuration = background ? URLSessionConfiguration.background(withIdentifier: self.backgroundSessionIdentifier) : URLSessionConfiguration.default + + var additionalHeaders: [String: AnyObject] = [:] + if let oAuthToken = self.oAuthToken { + additionalHeaders["Authorization"] = "Bearer \(oAuthToken)" as AnyObject + } + if let userAgent = self.userAgent { + additionalHeaders["User-Agent"] = userAgent as AnyObject + } + + configuration.httpAdditionalHeaders = additionalHeaders + + return configuration + } + + func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: AnyObject]? = nil, + fulfilling progress: Progress? = nil + ) async -> APIResult { + await perform(method, URLString: URLString, parameters: parameters, fulfilling: progress) { + try (JSONSerialization.jsonObject(with: $0) as AnyObject) + } + } + + func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: AnyObject]? = nil, + fulfilling progress: Progress? = nil, + jsonDecoder: JSONDecoder? = nil, + type: T.Type = T.self + ) async -> APIResult { + await perform(method, URLString: URLString, parameters: parameters, fulfilling: progress) { + let decoder = jsonDecoder ?? JSONDecoder() + return try decoder.decode(type, from: $0) + } + } + + private func perform( + _ method: HTTPRequestBuilder.Method, + URLString: String, + parameters: [String: AnyObject]?, + fulfilling progress: Progress?, + decoder: @escaping (Data) throws -> T + ) async -> APIResult { + var builder: HTTPRequestBuilder + do { + builder = try requestBuilder(URLString: URLString) + .method(method) + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + if let parameters { + if builder.method.allowsHTTPBody { + builder = builder.body(json: parameters as Any) + } else { + builder = builder.query(parameters) + } + } + + return await perform(request: builder, fulfilling: progress, decoder: decoder) + } + + func perform( + request: HTTPRequestBuilder, + fulfilling progress: Progress? = nil, + decoder: @escaping (Data) throws -> T, + taskCreated: ((Int) -> Void)? = nil, + session: URLSession? = nil + ) async -> APIResult { + await (session ?? self.urlSession) + .perform(request: request, taskCreated: taskCreated, fulfilling: progress, errorType: WordPressComRestApiEndpointError.self) + .mapSuccess { response -> HTTPAPIResponse in + let object = try decoder(response.body) + + return HTTPAPIResponse(response: response.response, body: object) + } + .mapUnacceptableStatusCodeError { response, body in + if let error = self.processError(response: response, body: body, additionalUserInfo: nil) { + return error + } + + throw URLError(.cannotParseResponse) + } + .mapError { error -> WordPressAPIError in + switch error { + case .requestEncodingFailure: + return .endpointError(.init(code: .requestSerializationFailed)) + case let .unparsableResponse(response, _, _): + return .endpointError(.init(code: .responseSerializationFailed, response: response)) + default: + return error + } + } + } + + public func upload( + URLString: String, + parameters: [String: AnyObject]? = nil, + httpHeaders: [String: String]? = nil, + fileParts: [FilePart], + requestEnqueued: RequestEnqueuedBlock? = nil, + fulfilling progress: Progress? = nil + ) async -> APIResult { + let builder: HTTPRequestBuilder + do { + let form = try fileParts.map { + try MultipartFormField(fileAtPath: $0.url.path, name: $0.parameterName, filename: $0.fileName, mimeType: $0.mimeType) + } + builder = try requestBuilder(URLString: URLString) + .method(.post) + .body(form: form) + .headers(httpHeaders ?? [:]) + } catch { + return .failure(.requestEncodingFailure(underlyingError: error)) + } + + return await perform( + request: builder.query(parameters ?? [:]), + fulfilling: progress, + decoder: { try JSONSerialization.jsonObject(with: $0) as AnyObject }, + taskCreated: { taskID in + DispatchQueue.main.async { + requestEnqueued?(NSNumber(value: taskID)) + } + }, + session: uploadURLSession + ) + } + +} + +// MARK: - Error processing + +extension WordPressComRestApi { + + func processError(response httpResponse: HTTPURLResponse, body data: Data, additionalUserInfo: [String: Any]?) -> WordPressComRestApiEndpointError? { + // Not sure if it's intentional to include 500 status code, but the code seems to be there from the very beginning. + // https://github.com/wordpress-mobile/WordPressKit-iOS/blob/1.0.1/WordPressKit/WordPressComRestApi.swift#L374 + guard (400...500).contains(httpResponse.statusCode) else { + return nil + } + + guard let responseObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), + let responseDictionary = responseObject as? [String: AnyObject] else { + + if let error = checkForThrottleErrorIn(response: httpResponse, data: data) { + return error + } + return .init(code: .unknown, response: httpResponse) + } + + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + if httpResponse.statusCode == 412, let code = responseDictionary["code"] as? String, code == "no_connected_jetpack" { + return .init(code: .preconditionFailure, response: httpResponse) + } + + var errorDictionary: AnyObject? = responseDictionary as AnyObject? + if let errorArray = responseDictionary["errors"] as? [AnyObject], errorArray.count > 0 { + errorDictionary = errorArray.first + } + guard let errorEntry = errorDictionary as? [String: AnyObject], + let errorCode = errorEntry["error"] as? String, + let errorDescription = errorEntry["message"] as? String + else { + return .init(code: .unknown, response: httpResponse) + } + + let errorsMap: [String: WordPressComRestApiErrorCode] = [ + "invalid_input": .invalidInput, + "invalid_token": .invalidToken, + "authorization_required": .authorizationRequired, + "upload_error": .uploadFailed, + "unauthorized": .authorizationRequired, + "invalid_query": .invalidQuery + ] + + let mappedError = errorsMap[errorCode] ?? .unknown + if mappedError == .invalidToken { + // Call `invalidTokenHandler in the main thread since it's typically used by the apps to present an authentication UI. + DispatchQueue.main.async { + self.invalidTokenHandler?() + } + } + + var originalErrorUserInfo = additionalUserInfo ?? [:] + originalErrorUserInfo.removeValue(forKey: NSLocalizedDescriptionKey) + + return .init( + code: mappedError, + apiErrorCode: errorCode, + apiErrorMessage: errorDescription, + apiErrorData: errorEntry["data"], + additionalUserInfo: originalErrorUserInfo + ) + } + + func checkForThrottleErrorIn(response: HTTPURLResponse, data: Data) -> WordPressComRestApiEndpointError? { + // This endpoint is throttled, so check if we've sent too many requests and fill that error in as + // when too many requests occur the API just spits out an html page. + guard let responseString = String(data: data, encoding: .utf8), + responseString.contains("Limit reached") else { + return nil + } + + let message = NSLocalizedString( + "wordpresskit.api.message.endpoint_throttled", + value: "Limit reached. You can try again in 1 minute. Trying again before that will only increase the time you have to wait before the ban is lifted. If you think this is in error, contact support.", + comment: "Message to show when a request for a WP.com API endpoint is throttled" + ) + return .init( + code: .tooManyRequests, + apiErrorCode: "too_many_requests", + apiErrorMessage: message + ) + } +} +// MARK: - Anonymous API support + +extension WordPressComRestApi { + + /// Returns an API object without an OAuth token defined & with the userAgent set for the WordPress App user agent + /// + @objc class public func anonymousApi(userAgent: String) -> WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, userAgent: userAgent) + } + + /// Returns an API object without an OAuth token defined & with both the userAgent & localeKey set for the WordPress App user agent + /// + @objc class public func anonymousApi(userAgent: String, localeKey: String) -> WordPressComRestApi { + return WordPressComRestApi(oAuthToken: nil, userAgent: userAgent, localeKey: localeKey) + } +} + +// MARK: - Constants + +private extension WordPressComRestApi { + + enum Constants { + static let buildRequestError = NSError(domain: WordPressComRestApiEndpointError.errorDomain, + code: WordPressComRestApiErrorCode.requestSerializationFailed.rawValue, + userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Failed to serialize request to the REST API.", + comment: "Error message to show when wrong URL format is used to access the REST API")]) + } +} + +// MARK: - POST encoding + +extension WordPressAPIError { + func asNSError() -> NSError { + // When encoutering `URLError`, return `URLError` to avoid potentially breaking existing error handling code in the apps. + if case let .connection(urlError) = self { + return urlError as NSError + } + + return self as NSError + } +} + +extension WordPressComRestApi: WordPressComRESTAPIInterfacing { + // A note on the naming: Even if defined as `GET` in Objective-C, then method gets converted to Swift as `get`. + // + // Also, there is no Objective-C direct equivalent of `AnyObject`, which here is used in `parameters: [String: AnyObject]?`. + // + // For those reasons, we can't immediately conform to `WordPressComRESTAPIInterfacing` and need instead to use this kind of wrapping. + // The same applies for the other methods below. + public func get( + _ URLString: String, + parameters: [String: Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + GET( + URLString, + // It's possible `WordPressComRestApi` could be updated to use `[String: Any]` instead. + // But leaving that investigation for later. + parameters: parameters as? [String: AnyObject], + success: success, + failure: failure + ) + } + + public func post( + _ URLString: String, + parameters: [String: Any]?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + POST( + URLString, + // It's possible `WordPressComRestApi` could be updated to use `[String: Any]` instead. + // But leaving that investigation for later. + parameters: parameters as? [String: AnyObject], + success: success, + failure: failure + ) + } + + public func multipartPOST( + _ URLString: String, + parameters: [String: NSObject]?, + fileParts: [FilePart], + // Notice this does not require @escaping because it is Optional. + // + // Annotate with @escaping, and the compiler will fail with: + // > Closure is already escaping in optional type argument + // + // It is necessary to explicitly set this as Optional because of the _Nullable parameter requirement in the Objective-C protocol. + requestEnqueued: ((NSNumber) -> Void)?, + success: @escaping (Any, HTTPURLResponse?) -> Void, + failure: @escaping (any Error, HTTPURLResponse?) -> Void + ) -> Progress? { + multipartPOST( + URLString, + parameters: parameters, + fileParts: fileParts, + requestEnqueued: requestEnqueued, + success: success as SuccessResponseBlock, + failure: failure as FailureReponseBlock + ) + } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressOrgRestApi.swift b/WordPressKit/Sources/CoreAPI/WordPressOrgRestApi.swift new file mode 100644 index 000000000000..337ea2980c3b --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressOrgRestApi.swift @@ -0,0 +1,269 @@ +import Foundation +import WordPressShared + +public struct WordPressOrgRestApiError: LocalizedError, Decodable, HTTPURLResponseProviding { + public enum CodingKeys: String, CodingKey { + case code, message + } + + public var code: String + public var message: String? + + var response: HTTPAPIResponse? + + var httpResponse: HTTPURLResponse? { + response?.response + } + + public var errorDescription: String? { + return message ?? NSLocalizedString( + "wordpresskit.org-rest-api.not-found", + value: "Couldn't find your site's REST API URL. The app needs that in order to communicate with your site. Contact your host to solve this problem.", + comment: "Message to show to user when the app can't find WordPress.org REST API URL." + ) + } +} + +@objc +public final class WordPressOrgRestApi: NSObject { + public struct SelfHostedSiteCredential { + public let loginURL: URL + public let username: String + public let password: Secret + public let adminURL: URL + + public init(loginURL: URL, username: String, password: String, adminURL: URL) { + self.loginURL = loginURL + self.username = username + self.password = .init(password) + self.adminURL = adminURL + } + } + + enum Site { + case dotCom(siteID: UInt64, bearerToken: String, apiURL: URL) + case selfHosted(apiURL: URL, credential: SelfHostedSiteCredential) + } + + let site: Site + let urlSession: URLSession + + var selfHostedSiteNonce: String? + + public convenience init(dotComSiteID: UInt64, bearerToken: String, userAgent: String? = nil, apiURL: URL = WordPressComRestApi.apiBaseURL) { + self.init(site: .dotCom(siteID: dotComSiteID, bearerToken: bearerToken, apiURL: apiURL), userAgent: userAgent) + } + + public convenience init(selfHostedSiteWPJSONURL apiURL: URL, credential: SelfHostedSiteCredential, userAgent: String? = nil) { + assert(apiURL.host != "public-api.wordpress.com", "Not a self-hosted site: \(apiURL)") + // Potential improvement(?): discover API URL instead. See https://developer.wordpress.org/rest-api/using-the-rest-api/discovery/ + assert(apiURL.lastPathComponent == "wp-json", "Not a REST API URL: \(apiURL)") + + self.init(site: .selfHosted(apiURL: apiURL, credential: credential), userAgent: userAgent) + } + + init(site: Site, userAgent: String? = nil) { + self.site = site + + var additionalHeaders = [String: String]() + if let userAgent { + additionalHeaders["User-Agent"] = userAgent + } + if case let Site.dotCom(_, token, _) = site { + additionalHeaders["Authorization"] = "Bearer \(token)" + } + + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = additionalHeaders + urlSession = URLSession(configuration: configuration) + } + + deinit { + urlSession.finishTasksAndInvalidate() + } + + @objc + public func invalidateAndCancelTasks() { + urlSession.invalidateAndCancel() + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, options: options) + } + + public func post( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } + + public func post( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, options: options) + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try jsonDecoder.decode(type, from: $0) + } + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try JSONSerialization.jsonObject(with: $0, options: options) + } + } + + private func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + decoder: @escaping (Data) throws -> Success + ) async -> WordPressAPIResult { + var builder = HTTPRequestBuilder(url: apiBaseURL()) + .dotOrgRESTAPI(route: path, site: site) + .method(method) + if method.allowsHTTPBody { + builder = builder.body(form: parameters ?? [:]) + } else { + builder = builder.query(parameters ?? [:]) + } + + return await perform(builder: builder) + .mapSuccess { try decoder($0.body) } + } + + func perform(builder originalBuilder: HTTPRequestBuilder) async -> WordPressAPIResult, WordPressOrgRestApiError> { + var builder = originalBuilder + + if case .selfHosted = site, let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + } + + var result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + + // When a self hosted site request fails with 401, authenticate and retry the request. + if case .selfHosted = site, + case let .failure(.unacceptableStatusCode(response, _)) = result, + response.statusCode == 401, + await refreshNonce(), + let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + } + + return result + .mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + do { + var endpointError = try JSONDecoder().decode(WordPressOrgRestApiError.self, from: body) + endpointError.response = HTTPAPIResponse(response: response, body: body) + return WordPressAPIError.endpointError(endpointError) + } catch { + return .unparsableResponse(response: response, body: body, underlyingError: error) + } + } + return error + } + } + +} + +// MARK: - Authentication + +private extension WordPressOrgRestApi { + func apiBaseURL() -> URL { + switch site { + case let .dotCom(_, _, apiURL): + return apiURL + case let .selfHosted(apiURL, _): + return apiURL + } + } + + /// Fetch REST API nonce from the site. + /// + /// - Returns true if the nonce is fetched and it's different than the cached one. + func refreshNonce() async -> Bool { + guard case let .selfHosted(_, credential) = site else { + return false + } + + var refreshed = false + + let methods: [NonceRetrievalMethod] = [.ajaxNonceRequest, .newPostScrap] + for method in methods { + guard let nonce = await method.retrieveNonce( + username: credential.username, + password: credential.password, + loginURL: credential.loginURL, + adminURL: credential.adminURL, + using: urlSession + ) else { + continue + } + + refreshed = selfHostedSiteNonce != nonce + + selfHostedSiteNonce = nonce + break + } + + return refreshed + } +} + +// MARK: - Helpers + +private extension HTTPRequestBuilder { + func dotOrgRESTAPI(route aRoute: String, site: WordPressOrgRestApi.Site) -> Self { + var route = aRoute + if !route.hasPrefix("/") { + route = "/" + route + } + + switch site { + case let .dotCom(siteID, _, _): + // Currently only the following namespaces are supported. When adding more supported namespaces, remember to + // update the "path adapter" code below for the REST API in WP.COM. + assert(route.hasPrefix("/wp/v2") || route.hasPrefix("/wp-block-editor/v1"), "Unsupported .org REST API route: \(route)") + route = route + .replacingOccurrences(of: "/wp/v2/", with: "/wp/v2/sites/\(siteID)/") + .replacingOccurrences(of: "/wp-block-editor/v1/", with: "/wp-block-editor/v1/sites/\(siteID)/") + case let .selfHosted(apiURL, _): + assert(apiURL.lastPathComponent == "wp-json") + } + + return appendURLString(route) + } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCApi.swift b/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCApi.swift new file mode 100644 index 000000000000..2698c89f56ec --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCApi.swift @@ -0,0 +1,432 @@ +import Foundation +import wpxmlrpc + +/// Class to connect to the XMLRPC API on self hosted sites. +open class WordPressOrgXMLRPCApi: NSObject { + public typealias SuccessResponseBlock = (AnyObject, HTTPURLResponse?) -> Void + public typealias FailureReponseBlock = (_ error: NSError, _ httpResponse: HTTPURLResponse?) -> Void + + @available(*, deprecated, message: "This property is no longer being used because WordPressKit now sends all HTTP requests using `URLSession` directly.") + public static var useURLSession = true + + private let endpoint: URL + private let userAgent: String? + private var backgroundUploads: Bool + private var backgroundSessionIdentifier: String + @objc public static let defaultBackgroundSessionIdentifier = "org.wordpress.wporgxmlrpcapi" + + /// onChallenge's Callback Closure Signature. Host Apps should call this method, whenever a proper AuthChallengeDisposition has been + /// picked up (optionally with URLCredentials!). + /// + public typealias AuthenticationHandler = (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + + /// Closure to be executed whenever we receive a URLSession Authentication Challenge. + /// + public static var onChallenge: ((URLAuthenticationChallenge, @escaping AuthenticationHandler) -> Void)? + + /// Minimum WordPress.org Supported Version. + /// + @objc public static let minimumSupportedVersion = "4.0" + + private lazy var urlSession: URLSession = makeSession(configuration: .default) + private lazy var uploadURLSession: URLSession = { + backgroundUploads + ? makeSession(configuration: .background(withIdentifier: self.backgroundSessionIdentifier)) + : urlSession + }() + + private func makeSession(configuration sessionConfiguration: URLSessionConfiguration) -> URLSession { + var additionalHeaders: [String: AnyObject] = ["Accept-Encoding": "gzip, deflate" as AnyObject] + if let userAgent = self.userAgent { + additionalHeaders["User-Agent"] = userAgent as AnyObject? + } + sessionConfiguration.httpAdditionalHeaders = additionalHeaders + // When using a background URLSession, we don't need to apply the authentication challenge related + // implementations in `SessionDelegate`. + if sessionConfiguration.identifier != nil { + return URLSession.backgroundSession(configuration: sessionConfiguration) + } else { + return URLSession(configuration: sessionConfiguration, delegate: sessionDelegate, delegateQueue: nil) + } + } + + // swiftlint:disable weak_delegate + /// `URLSessionDelegate` for the URLSession instances in this class. + private let sessionDelegate = SessionDelegate() + // swiftlint:enable weak_delegate + + /// Creates a new API object to connect to the WordPress XMLRPC API for the specified endpoint. + /// + /// - Parameters: + /// - endpoint: the endpoint to connect to the xmlrpc api interface. + /// - userAgent: the user agent to use on the connection. + /// - backgroundUploads: If this value is true the API object will use a background session to execute uploads requests when using the `multipartPOST` function. The default value is false. + /// - backgroundSessionIdentifier: The session identifier to use for the background session. This must be unique in the system. + @objc public init(endpoint: URL, userAgent: String? = nil, backgroundUploads: Bool = false, backgroundSessionIdentifier: String) { + self.endpoint = endpoint + self.userAgent = userAgent + self.backgroundUploads = backgroundUploads + self.backgroundSessionIdentifier = backgroundSessionIdentifier + super.init() + } + + /// Creates a new API object to connect to the WordPress XMLRPC API for the specified endpoint. The background uploads are disabled when using this initializer. + /// + /// - Parameters: + /// - endpoint: the endpoint to connect to the xmlrpc api interface. + /// - userAgent: the user agent to use on the connection. + @objc convenience public init(endpoint: URL, userAgent: String? = nil) { + self.init(endpoint: endpoint, userAgent: userAgent, backgroundUploads: false, backgroundSessionIdentifier: WordPressOrgXMLRPCApi.defaultBackgroundSessionIdentifier + "." + endpoint.absoluteString) + } + + deinit { + for session in [urlSession, uploadURLSession] { + session.finishTasksAndInvalidate() + } + } + + /** + Cancels all ongoing and makes the session so the object will not fullfil any more request + */ + @objc open func invalidateAndCancelTasks() { + for session in [urlSession, uploadURLSession] { + session.invalidateAndCancel() + } + } + + // MARK: - Network requests + /** + Check if username and password are valid credentials for the xmlrpc endpoint. + + - parameter username: username to check + - parameter password: password to check + - parameter success: callback block to be invoked if credentials are valid, the object returned in the block is the options dictionary for the site. + - parameter failure: callback block to be invoked is credentials fail + */ + @objc open func checkCredentials(_ username: String, + password: String, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) { + let parameters: [AnyObject] = [0 as AnyObject, username as AnyObject, password as AnyObject] + callMethod("wp.getOptions", parameters: parameters, success: success, failure: failure) + } + /** + Executes a XMLRPC call for the method specificied with the arguments provided. + + - parameter method: the xmlrpc method to be invoked + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func callMethod(_ method: String, + parameters: [AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + Task { @MainActor in + let result = await self.call(method: method, parameters: parameters, fulfilling: progress, streaming: false) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + return progress + } + + /** + Executes a XMLRPC call for the method specificied with the arguments provided, by streaming the request from a file. + This allows to do requests that can use a lot of memory, like media uploads. + + - parameter method: the xmlrpc method to be invoked + - parameter parameters: the parameters to be encoded on the request + - parameter success: callback to be called on successful request + - parameter failure: callback to be called on failed request + + - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method + returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback + will be invoked with the error specificing the serialization issues. + */ + @objc @discardableResult open func streamCallMethod(_ method: String, + parameters: [AnyObject]?, + success: @escaping SuccessResponseBlock, + failure: @escaping FailureReponseBlock) -> Progress? { + let progress = Progress.discreteProgress(totalUnitCount: 100) + Task { @MainActor in + let result = await self.call(method: method, parameters: parameters, fulfilling: progress, streaming: true) + switch result { + case let .success(response): + success(response.body, response.response) + case let .failure(error): + failure(error.asNSError(), error.response) + } + } + return progress + } + + /// Call an XMLRPC method. + /// + /// ## Error handling + /// + /// Unlike the closure-based APIs, this method returns a concrete error type. You should consider handling the errors + /// as they are, instead of casting them to `NSError` instance. But in case you do need to cast them to `NSError`, + /// considering using the `asNSError` function if you need backward compatibility with existing code. + /// + /// - Parameters: + /// - streaming: set to `true` if there are large data (i.e. uploading files) in given `parameters`. `false` by default. + /// - Returns: A `Result` type that contains the XMLRPC success or failure result. + func call(method: String, parameters: [AnyObject]?, fulfilling progress: Progress? = nil, streaming: Bool = false) async -> WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + let session = streaming ? uploadURLSession : urlSession + let builder = HTTPRequestBuilder(url: endpoint) + .method(.post) + .body(xmlrpc: method, parameters: parameters) + return await session + .perform( + request: builder, + // All HTTP responses are treated as successful result. Error handling will be done in `decodeXMLRPCResult`. + acceptableStatusCodes: [1...999], + fulfilling: progress, + errorType: WordPressOrgXMLRPCApiFault.self + ) + .decodeXMLRPCResult() + } + + @objc public static let WordPressOrgXMLRPCApiErrorKeyData: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyData" + @objc public static let WordPressOrgXMLRPCApiErrorKeyDataString: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyDataString" + @objc public static let WordPressOrgXMLRPCApiErrorKeyStatusCode: NSError.UserInfoKey = "WordPressOrgXMLRPCApiErrorKeyStatusCode" + + fileprivate static func convertError(_ error: NSError, data: Data?, statusCode: Int? = nil) -> NSError { + let responseCode = statusCode == 403 ? 403 : error.code + if let data = data { + var userInfo: [String: Any] = error.userInfo + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyData as String] = data + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyDataString as String] = NSString(data: data, encoding: String.Encoding.utf8.rawValue) + userInfo[Self.WordPressOrgXMLRPCApiErrorKeyStatusCode as String] = statusCode + userInfo[NSLocalizedFailureErrorKey] = error.localizedDescription + + if let statusCode = statusCode, (400..<600).contains(statusCode) { + let formatString = NSLocalizedString("An HTTP error code %i was returned.", comment: "A failure reason for when an error HTTP status code was returned from the site, with the specific error code.") + userInfo[NSLocalizedFailureReasonErrorKey] = String(format: formatString, statusCode) + } else { + userInfo[NSLocalizedFailureReasonErrorKey] = error.localizedFailureReason + } + + return NSError(domain: error.domain, code: responseCode, userInfo: userInfo) + } + return error + } +} + +private class SessionDelegate: NSObject, URLSessionDelegate { + + @objc func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + if let credential = URLCredentialStorage.shared.defaultCredential(for: challenge.protectionSpace), challenge.previousFailureCount == 0 { + completionHandler(.useCredential, credential) + return + } + + guard let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.performDefaultHandling, nil) + return + } + + _ = SecTrustEvaluateWithError(serverTrust, nil) + var result = SecTrustResultType.invalid + let certificateStatus = SecTrustGetTrustResult(serverTrust, &result) + + guard let hostAppHandler = WordPressOrgXMLRPCApi.onChallenge, certificateStatus == 0, result == .recoverableTrustFailure else { + completionHandler(.performDefaultHandling, nil) + return + } + + DispatchQueue.main.async { + hostAppHandler(challenge, completionHandler) + } + + default: + completionHandler(.performDefaultHandling, nil) + } + } + + @objc func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodHTTPBasic: + if let credential = URLCredentialStorage.shared.defaultCredential(for: challenge.protectionSpace), challenge.previousFailureCount == 0 { + completionHandler(.useCredential, credential) + return + } + + guard let hostAppHandler = WordPressOrgXMLRPCApi.onChallenge else { + completionHandler(.performDefaultHandling, nil) + return + } + + DispatchQueue.main.async { + hostAppHandler(challenge, completionHandler) + } + + default: + completionHandler(.performDefaultHandling, nil) + } + } +} + +/// Error constants for the WordPress XML-RPC API +@objc public enum WordPressOrgXMLRPCApiError: Int, Error { + /// An error HTTP status code was returned. + case httpErrorStatusCode + /// The serialization of the request failed. + case requestSerializationFailed + /// The serialization of the response failed. + case responseSerializationFailed + /// An unknown error occurred. + case unknown +} + +extension WordPressOrgXMLRPCApiError: LocalizedError { + public var errorDescription: String? { + return NSLocalizedString("There was a problem communicating with the site.", comment: "A general error message shown to the user when there was an API communication failure.") + } + + public var failureReason: String? { + switch self { + case .httpErrorStatusCode: + return NSLocalizedString("An HTTP error code was returned.", comment: "A failure reason for when an error HTTP status code was returned from the site.") + case .requestSerializationFailed: + return NSLocalizedString("The serialization of the request failed.", comment: "A failure reason for when the request couldn't be serialized.") + case .responseSerializationFailed: + return NSLocalizedString("The serialization of the response failed.", comment: "A failure reason for when the response couldn't be serialized.") + case .unknown: + return NSLocalizedString("An unknown error occurred.", comment: "A failure reason for when the error that occured wasn't able to be determined.") + } + } +} + +public struct WordPressOrgXMLRPCApiFault: LocalizedError, HTTPURLResponseProviding { + public var response: HTTPAPIResponse + public let code: Int? + public let message: String? + + public init(response: HTTPAPIResponse, code: Int?, message: String?) { + self.response = response + self.code = code + self.message = message + } + + public var errorDescription: String? { + message + } + + public var httpResponse: HTTPURLResponse? { + response.response + } +} + +private extension WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + + func decodeXMLRPCResult() -> WordPressAPIResult, WordPressOrgXMLRPCApiFault> { + // This is a re-implementation of `WordPressOrgXMLRPCApi.handleResponseWithData` function: + // https://github.com/wordpress-mobile/WordPressKit-iOS/blob/11.0.0/WordPressKit/WordPressOrgXMLRPCApi.swift#L265 + flatMap { response in + guard let contentType = response.response.allHeaderFields["Content-Type"] as? String else { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: WordPressOrgXMLRPCApiError.unknown)) + } + + if (400..<600).contains(response.response.statusCode) { + if let decoder = WPXMLRPCDecoder(data: response.body), decoder.isFault() { + // when XML-RPC is disabled for authenticated calls (e.g. xmlrpc_enabled is false on WP.org), + // it will return a valid fault payload with a non-200 + return .failure(.endpointError(.init(response: response, code: decoder.faultCode(), message: decoder.faultString()))) + } else { + return .failure(.unacceptableStatusCode(response: response.response, body: response.body)) + } + } + + guard contentType.hasPrefix("application/xml") || contentType.hasPrefix("text/xml") else { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: WordPressOrgXMLRPCApiError.unknown)) + } + + guard let decoder = WPXMLRPCDecoder(data: response.body) else { + return .failure(.unparsableResponse(response: response.response, body: response.body)) + } + + guard !decoder.isFault() else { + return .failure(.endpointError(.init(response: response, code: decoder.faultCode(), message: decoder.faultString()))) + } + + if let decoderError = decoder.error() { + return .failure(.unparsableResponse(response: response.response, body: response.body, underlyingError: decoderError)) + } + + guard let responseXML = decoder.object() else { + return .failure(.unparsableResponse(response: response.response, body: response.body)) + } + + return .success(HTTPAPIResponse(response: response.response, body: responseXML as AnyObject)) + } + } + +} + +private extension WordPressAPIError where EndpointError == WordPressOrgXMLRPCApiFault { + + /// Convert to NSError for backwards compatiblity. + /// + /// Some Objective-C code in the WordPress app checks domain of the errors returned by `WordPressOrgXMLRPCApi`, + /// which can be WordPressOrgXMLRPCApiError or WPXMLRPCFaultErrorDomain. + /// + /// Swift code should avoid dealing with NSError instances. Instead, they should use the strongly typed + /// `WordPressAPIError`. + func asNSError() -> NSError { + let error: NSError + let data: Data? + let statusCode: Int? + switch self { + case let .requestEncodingFailure(underlyingError): + error = underlyingError as NSError + data = nil + statusCode = nil + case let .connection(urlError): + error = urlError as NSError + data = nil + statusCode = nil + case let .endpointError(fault): + error = NSError(domain: WPXMLRPCFaultErrorDomain, code: fault.code ?? 0, userInfo: [NSLocalizedDescriptionKey: fault.message].compactMapValues { $0 }) + data = fault.response.body + statusCode = nil + case let .unacceptableStatusCode(response, body): + error = WordPressOrgXMLRPCApiError.httpErrorStatusCode as NSError + data = body + statusCode = response.statusCode + case let .unparsableResponse(_, body, underlyingError): + error = underlyingError as NSError + data = body + statusCode = nil + case let .unknown(underlyingError): + error = underlyingError as NSError + data = nil + statusCode = nil + } + + return WordPressOrgXMLRPCApi.convertError(error, data: data, statusCode: statusCode) + } + +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCValidator.swift b/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCValidator.swift new file mode 100644 index 000000000000..c26d54abd91c --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressOrgXMLRPCValidator.swift @@ -0,0 +1,366 @@ +import Foundation + +@objc public enum WordPressOrgXMLRPCValidatorError: Int, Error { + case emptyURL // The URL provided was nil, empty or just whitespaces + case invalidURL // The URL provided was an invalid URL + case invalidScheme // The URL provided was an invalid scheme, only HTTP and HTTPS supported + case notWordPressError // That's a XML-RPC endpoint but doesn't look like WordPress + case mobilePluginRedirectedError // There's some "mobile" plugin redirecting everything to their site + case forbidden = 403 // Server returned a 403 error while reading xmlrpc file + case blocked = 405 // Server returned a 405 error while reading xmlrpc file + case invalid // Doesn't look to be valid XMLRPC Endpoint. + case xmlrpc_missing // site contains RSD link but XML-RPC information is missing + + public var localizedDescription: String { + switch self { + case .emptyURL: + return NSLocalizedString("Empty URL", comment: "Message to show to user when he tries to add a self-hosted site that is an empty URL.") + case .invalidURL: + return NSLocalizedString("Invalid URL, please check if you wrote a valid site address.", comment: "Message to show to user when he tries to add a self-hosted site that isn't a valid URL.") + case .invalidScheme: + return NSLocalizedString("Invalid URL scheme inserted, only HTTP and HTTPS are supported.", comment: "Message to show to user when he tries to add a self-hosted site that isn't HTTP or HTTPS.") + case .notWordPressError: + return NSLocalizedString("That doesn't look like a WordPress site.", comment: "Message to show to user when he tries to add a self-hosted site that isn't a WordPress site.") + case .mobilePluginRedirectedError: + return NSLocalizedString( + "You seem to have installed a mobile plugin from DudaMobile which is preventing the app to connect to your blog", + comment: "Error messaged show when a mobile plugin is redirecting traffict to their site, DudaMobile in particular" + ) + case .invalid: + return NSLocalizedString("Couldn't connect to the WordPress site. There is no valid WordPress site at this address. Check the site address (URL) you entered.", comment: "Error message shown a URL points to a valid site but not a WordPress site.") + case .blocked: + return NSLocalizedString("Couldn't connect. Your host is blocking POST requests, and the app needs that in order to communicate with your site. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site but the host returned a 405 error, meaning that the host is blocking POST requests on /xmlrpc.php file.") + case .forbidden: + return NSLocalizedString("Couldn't connect. We received a 403 error when trying to access your site's XMLRPC endpoint. The app needs that in order to communicate with your site. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site but the host returned a 403 error, meaning that the access to the /xmlrpc.php file is forbidden.") + case .xmlrpc_missing: + return NSLocalizedString("Couldn't connect. Required XML-RPC methods are missing on the server. Please contact your hosting provider to solve this problem.", comment: "Message to show to user when he tries to add a self-hosted site with RSD link present, but xmlrpc is missing.") + } + } +} + +extension WordPressOrgXMLRPCValidatorError: LocalizedError { + public var errorDescription: String? { + localizedDescription + } +} + +/// An WordPressOrgXMLRPCValidator is able to validate and check if user provided site urls are +/// WordPress XMLRPC sites. +open class WordPressOrgXMLRPCValidator: NSObject { + + // The documentation for NSURLErrorHTTPTooManyRedirects says that 16 + // is the default threshold for allowable redirects. + private let redirectLimit = 16 + + private let appTransportSecuritySettings: AppTransportSecuritySettings + + override public init() { + appTransportSecuritySettings = AppTransportSecuritySettings() + super.init() + } + + init(_ appTransportSecuritySettings: AppTransportSecuritySettings) { + self.appTransportSecuritySettings = appTransportSecuritySettings + super.init() + } + + /** + Validates and check if user provided site urls are WordPress XMLRPC sites and returns the API endpoint. + + - parameter site: the user provided site URL + - parameter userAgent: user agent for anonymous .com API to check if a site is a Jetpack site + - parameter success: completion handler that is invoked when the site is considered valid, + the xmlrpcURL argument is the endpoint + - parameter failure: completion handler that is invoked when the site is considered invalid, + the error object provides details why the endpoint is invalid + */ + @objc open func guessXMLRPCURLForSite(_ site: String, + userAgent: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + var sitesToTry = [String]() + + let secureAccessOnly: Bool = { + if let url = URL(string: site) { + return appTransportSecuritySettings.secureAccessOnly(for: url) + } + return true + }() + + if site.hasPrefix("http://") { + if !secureAccessOnly { + sitesToTry.append(site) + } + sitesToTry.append(site.replacingOccurrences(of: "http://", with: "https://")) + } else if site.hasPrefix("https://") { + sitesToTry.append(site) + if !secureAccessOnly { + sitesToTry.append(site.replacingOccurrences(of: "https://", with: "http://")) + } + } else { + failure(WordPressOrgXMLRPCValidatorError.invalidScheme as NSError) + return + } + + tryGuessXMLRPCURLForSites(sitesToTry, userAgent: userAgent, success: success, failure: failure) + } + + /// Helper for `guessXMLRPCURLForSite(_:userAgent:success:failure)` + /// Tries to guess the XMLRPC url for all the sites string given in the sites array + /// If all of them fail, it will call `failure` with the error in the last url string. + /// + private func tryGuessXMLRPCURLForSites(_ sites: [String], + userAgent: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + guard sites.isEmpty == false else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + + var mutableSites = sites + let nextSite = mutableSites.removeFirst() + + func errorHandler(_ error: NSError) { + if mutableSites.isEmpty { + failure(error) + } else { + tryGuessXMLRPCURLForSites(mutableSites, userAgent: userAgent, success: success, failure: failure) + } + } + + let originalXMLRPCURL: URL + let xmlrpcURL: URL + do { + xmlrpcURL = try urlForXMLRPCFromURLString(nextSite, addXMLRPC: true) + originalXMLRPCURL = try urlForXMLRPCFromURLString(nextSite, addXMLRPC: false) + } catch let error as NSError { + // WPKitLogError(error.localizedDescription) + errorHandler(error) + return + } + + validateXMLRPCURL(xmlrpcURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + if (error.domain == NSURLErrorDomain && error.code == NSURLErrorUserCancelledAuthentication) || + (error.domain == NSURLErrorDomain && error.code == NSURLErrorCannotFindHost) || + (error.domain == NSURLErrorDomain && error.code == NSURLErrorNetworkConnectionLost) || + (error.domain == String(reflecting: WordPressOrgXMLRPCValidatorError.self) && error.code == WordPressOrgXMLRPCValidatorError.mobilePluginRedirectedError.rawValue) { + errorHandler(error) + return + } + // Try the original given url as an XML-RPC endpoint + // WPKitLogError("Try the original given url as an XML-RPC endpoint: \(originalXMLRPCURL)") + self.validateXMLRPCURL(originalXMLRPCURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + // Fetch the original url and look for the RSD link + self.guessXMLRPCURLFromHTMLURL(originalXMLRPCURL, success: success, failure: { (error) in + // WPKitLogError(error.localizedDescription) + + errorHandler(error) + }) + }) + }) + } + + private func urlForXMLRPCFromURLString(_ urlString: String, addXMLRPC: Bool) throws -> URL { + var resultURLString = urlString + // Is an empty url? Sorry, no psychic powers yet + resultURLString = urlString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if resultURLString.isEmpty { + throw WordPressOrgXMLRPCValidatorError.emptyURL + } + + // Check if it's a valid URL + // Not a valid URL. Could be a bad protocol (htpp://), syntax error (http//), ... + // See https://github.com/koke/NSURL-Guess for extra help cleaning user typed URLs + guard let baseURL = URL(string: resultURLString) else { + throw WordPressOrgXMLRPCValidatorError.invalidURL + } + + // Let's see if a scheme is provided and it's HTTP or HTTPS + var scheme = baseURL.scheme!.lowercased() + if scheme.isEmpty { + resultURLString = "http://\(resultURLString)" + scheme = "http" + } + + guard scheme == "http" || scheme == "https" else { + throw WordPressOrgXMLRPCValidatorError.invalidScheme + } + + if baseURL.lastPathComponent != "xmlrpc.php" && addXMLRPC { + // Assume the given url is the home page and XML-RPC sits at /xmlrpc.php + // WPKitLogInfo("Assume the given url is the home page and XML-RPC sits at /xmlrpc.php") + resultURLString = "\(resultURLString)/xmlrpc.php" + } + + guard let url = URL(string: resultURLString) else { + throw WordPressOrgXMLRPCValidatorError.invalid + } + + return url + } + + private func validateXMLRPCURL(_ url: URL, + redirectCount: Int = 0, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + + guard redirectCount < redirectLimit else { + let error = NSError(domain: URLError.errorDomain, + code: URLError.httpTooManyRedirects.rawValue, + userInfo: nil) + failure(error) + return + } + let api = WordPressOrgXMLRPCApi(endpoint: url) + api.callMethod("system.listMethods", parameters: nil, success: { (responseObject, httpResponse) in + guard let methods = responseObject as? [String], methods.contains("wp.getUsersBlogs") else { + failure(WordPressOrgXMLRPCValidatorError.notWordPressError as NSError) + return + } + if let finalURL = httpResponse?.url { + success(finalURL) + } else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + } + }, failure: { (error, httpResponse) in + if httpResponse?.url != url { + // we where redirected, let's check the answer content + if let data = error.userInfo[WordPressOrgXMLRPCApi.WordPressOrgXMLRPCApiErrorKeyData as String] as? Data, + let responseString = String(data: data, encoding: String.Encoding.utf8), responseString.range(of: "") != nil + || responseString.range(of: "dm404Container") != nil { + failure(WordPressOrgXMLRPCValidatorError.mobilePluginRedirectedError as NSError) + return + } + // If it's a redirect to the same host + // and the response is a '405 Method Not Allowed' + if let responseUrl = httpResponse?.url, + responseUrl.host == url.host + && httpResponse?.statusCode == 405 { + // Then it's likely a good redirect, but the POST + // turned into a GET. + // Let's retry the request at the new URL. + self.validateXMLRPCURL(responseUrl, redirectCount: redirectCount + 1, success: success, failure: failure) + return + } + } + + switch httpResponse?.statusCode { + case .some(WordPressOrgXMLRPCValidatorError.forbidden.rawValue): + failure(WordPressOrgXMLRPCValidatorError.forbidden as NSError) + case .some(WordPressOrgXMLRPCValidatorError.blocked.rawValue): + failure(WordPressOrgXMLRPCValidatorError.blocked as NSError) + default: + failure(error) + } + }) + } + + private func guessXMLRPCURLFromHTMLURL(_ htmlURL: URL, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + // WPKitLogInfo("Fetch the original url and look for the RSD link by using RegExp") + + var isWpSite = false + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + let dataTask = session.dataTask(with: htmlURL, completionHandler: { (data, _, error) in + if let error = error { + failure(error as NSError) + return + } + guard let data = data, + let responseString = String(data: data, encoding: String.Encoding.utf8), + let rsdURL = self.extractRSDURLFromHTML(responseString) + else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + + // If the site contains RSD link, it is WP.org site + isWpSite = true + + // Try removing "?rsd" from the url, it should point to the XML-RPC endpoint + let xmlrpc = rsdURL.replacingOccurrences(of: "?rsd", with: "") + if xmlrpc != rsdURL { + guard let newURL = URL(string: xmlrpc) else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + self.validateXMLRPCURL(newURL, success: success, failure: { (error) in + // Try to validate by using the RSD file directly + if error.code == 403 || error.code == 405, let xmlrpcValidatorError = error as? WordPressOrgXMLRPCValidatorError { + failure(xmlrpcValidatorError as NSError) + } else { + let validatorError = isWpSite ? WordPressOrgXMLRPCValidatorError.xmlrpc_missing : + WordPressOrgXMLRPCValidatorError.invalid + failure(validatorError as NSError) + } + }) + } else { + // Try to validate by using the RSD file directly + self.guessXMLRPCURLFromRSD(rsdURL, success: success, failure: failure) + } + }) + dataTask.resume() + } + + private func extractRSDURLFromHTML(_ html: String) -> String? { + guard let rsdURLRegExp = try? NSRegularExpression(pattern: "", + options: [.caseInsensitive]) + else { + return nil + } + + let matches = rsdURLRegExp.matches(in: html, + options: NSRegularExpression.MatchingOptions(), + range: NSRange(location: 0, length: html.count)) + if matches.count <= 0 { + return nil + } + +#if swift(>=4.0) + let rsdURLRange = matches[0].range(at: 1) +#else + let rsdURLRange = matches[0].rangeAt(1) +#endif + + if rsdURLRange.location == NSNotFound { + return nil + } + let rsdURL = (html as NSString).substring(with: rsdURLRange) + return rsdURL + } + + private func guessXMLRPCURLFromRSD(_ rsd: String, + success: @escaping (_ xmlrpcURL: URL) -> Void, + failure: @escaping (_ error: NSError) -> Void) { + // WPKitLogInfo("Parse the RSD document at the following URL: \(rsd)") + guard let rsdURL = URL(string: rsd) else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + let dataTask = session.dataTask(with: rsdURL, completionHandler: { (data, _, error) in + if let error = error { + failure(error as NSError) + return + } + guard let data = data, + let responseString = String(data: data, encoding: String.Encoding.utf8), + let parser = WordPressRSDParser(xmlString: responseString), + let xmlrpc = try? parser.parsedEndpoint(), + let xmlrpcURL = URL(string: xmlrpc) + else { + failure(WordPressOrgXMLRPCValidatorError.invalid as NSError) + return + } + // WPKitLogInfo("Bingo! We found the WordPress XML-RPC element: \(xmlrpcURL)") + self.validateXMLRPCURL(xmlrpcURL, success: success, failure: failure) + }) + dataTask.resume() + } +} diff --git a/WordPressKit/Sources/CoreAPI/WordPressRSDParser.swift b/WordPressKit/Sources/CoreAPI/WordPressRSDParser.swift new file mode 100644 index 000000000000..5d6d1f784013 --- /dev/null +++ b/WordPressKit/Sources/CoreAPI/WordPressRSDParser.swift @@ -0,0 +1,53 @@ +import Foundation + +/// An WordPressRSDParser is able to parse an RSD file and search for the XMLRPC WordPress url. +open class WordPressRSDParser: NSObject, XMLParserDelegate { + + private let parser: XMLParser + private var endpoint: String? + + @objc init?(xmlString: String) { + guard let data = xmlString.data(using: String.Encoding.utf8) else { + return nil + } + parser = XMLParser(data: data) + super.init() + parser.delegate = self + } + + func parsedEndpoint() throws -> String? { + if parser.parse() { + return endpoint + } + // Return the 'WordPress' API link, if found. + if let endpoint { + return endpoint + } + guard let error = parser.parserError else { + return nil + } + throw error + } + + // MARK: - NSXMLParserDelegate + open func parser(_ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName qName: String?, + attributes attributeDict: [String: String]) { + if elementName == "api" { + if let apiName = attributeDict["name"], apiName == "WordPress" { + if let endpoint = attributeDict["apiLink"] { + self.endpoint = endpoint + } else { + parser.abortParsing() + } + } + } + } + + open func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + // WPKitLogInfo("Error parsing RSD: \(parseError)") + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Info.plist b/WordPressKit/Sources/WordPressKit/Info.plist new file mode 100644 index 000000000000..ec0cc7b0cb4a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSPrincipalClass + + + diff --git a/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.h b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.h new file mode 100644 index 000000000000..71b827aafbaa --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.h @@ -0,0 +1,22 @@ +#import + +@import WordPressShared; + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXTERN id _Nullable WPKitGetLoggingDelegate(void); +FOUNDATION_EXTERN void WPKitSetLoggingDelegate(id _Nullable logger); + +FOUNDATION_EXTERN void WPKitLogError(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogWarning(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogInfo(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogDebug(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); +FOUNDATION_EXTERN void WPKitLogVerbose(NSString *str, ...) NS_FORMAT_FUNCTION(1, 2); + +FOUNDATION_EXTERN void WPKitLogvError(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvWarning(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvInfo(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvDebug(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); +FOUNDATION_EXTERN void WPKitLogvVerbose(NSString *str, va_list args) NS_FORMAT_FUNCTION(1, 0); + +NS_ASSUME_NONNULL_END diff --git a/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.m b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.m new file mode 100644 index 000000000000..59757186ba1d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.m @@ -0,0 +1,49 @@ +#import "WPKitLogging.h" + +static id wordPressKitLogger = nil; + +id _Nullable WPKitGetLoggingDelegate(void) +{ + return wordPressKitLogger; +} + +void WPKitSetLoggingDelegate(id _Nullable logger) +{ + wordPressKitLogger = logger; +} + +#define WPKitLogv(logFunc) \ + ({ \ + id logger = WPKitGetLoggingDelegate(); \ + if (logger == NULL) { \ + NSLog(@"[WordPressKit] Warning: please call `WPKitSetLoggingDelegate` to set a error logger."); \ + return; \ + } \ + if (![logger respondsToSelector:@selector(logFunc)]) { \ + NSLog(@"[WordPressKit] Warning: %@ does not implement " #logFunc, logger); \ + return; \ + } \ + /* Originally `performSelector:withObject:` was used to call the logging function, but for unknown reason */ \ + /* it causes a crash on `objc_retain`. So I have to switch to this strange "syntax" to call the logging function directly. */ \ + [logger logFunc [[NSString alloc] initWithFormat:str arguments:args]]; \ + }) + +#define WPKitLog(logFunc) \ + ({ \ + va_list args; \ + va_start(args, str); \ + WPKitLogv(logFunc); \ + va_end(args); \ + }) + +void WPKitLogError(NSString *str, ...) { WPKitLog(logError:); } +void WPKitLogWarning(NSString *str, ...) { WPKitLog(logWarning:); } +void WPKitLogInfo(NSString *str, ...) { WPKitLog(logInfo:); } +void WPKitLogDebug(NSString *str, ...) { WPKitLog(logDebug:); } +void WPKitLogVerbose(NSString *str, ...) { WPKitLog(logVerbose:); } + +void WPKitLogvError(NSString *str, va_list args) { WPKitLogv(logError:); } +void WPKitLogvWarning(NSString *str, va_list args) { WPKitLogv(logWarning:); } +void WPKitLogvInfo(NSString *str, va_list args) { WPKitLogv(logInfo:); } +void WPKitLogvDebug(NSString *str, va_list args) { WPKitLogv(logDebug:); } +void WPKitLogvVerbose(NSString *str, va_list args) { WPKitLogv(logVerbose:); } diff --git a/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.swift b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.swift new file mode 100644 index 000000000000..f01875b94e7b --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Logging/WPKitLogging.swift @@ -0,0 +1,19 @@ +func WPKitLogError(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvError(format, $0) } +} + +func WPKitLogWarning(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvWarning(format, $0) } +} + +func WPKitLogInfo(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvInfo(format, $0) } +} + +func WPKitLogDebug(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvDebug(format, $0) } +} + +func WPKitLogVerbose(_ format: String, _ arguments: CVarArg...) { + withVaList(arguments) { WPKitLogvVerbose(format, $0) } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/AccountSettings.swift b/WordPressKit/Sources/WordPressKit/Models/AccountSettings.swift new file mode 100644 index 000000000000..7cf0ab200986 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/AccountSettings.swift @@ -0,0 +1,95 @@ +import Foundation + +public struct AccountSettings { + // MARK: - My Profile + public let firstName: String // first_name + public let lastName: String // last_name + public let displayName: String // display_name + public let aboutMe: String // description + + // MARK: - Account Settings + public let username: String // user_login + public let usernameCanBeChanged: Bool // user_login_can_be_changed + public let email: String // user_email + public let emailPendingAddress: String? // new_user_email + public let emailPendingChange: Bool // user_email_change_pending + public let primarySiteID: Int // primary_site_ID + public let webAddress: String // user_URL + public let language: String // language + public let tracksOptOut: Bool + public let blockEmailNotifications: Bool + public let twoStepEnabled: Bool // two_step_enabled + + public init(firstName: String, + lastName: String, + displayName: String, + aboutMe: String, + username: String, + usernameCanBeChanged: Bool, + email: String, + emailPendingAddress: String?, + emailPendingChange: Bool, + primarySiteID: Int, + webAddress: String, + language: String, + tracksOptOut: Bool, + blockEmailNotifications: Bool, + twoStepEnabled: Bool) { + self.firstName = firstName + self.lastName = lastName + self.displayName = displayName + self.aboutMe = aboutMe + self.username = username + self.usernameCanBeChanged = usernameCanBeChanged + self.email = email + self.emailPendingAddress = emailPendingAddress + self.emailPendingChange = emailPendingChange + self.primarySiteID = primarySiteID + self.webAddress = webAddress + self.language = language + self.tracksOptOut = tracksOptOut + self.blockEmailNotifications = blockEmailNotifications + self.twoStepEnabled = twoStepEnabled + } +} + +public enum AccountSettingsChange { + case firstName(String) + case lastName(String) + case displayName(String) + case aboutMe(String) + case email(String) + case emailRevertPendingChange + case primarySite(Int) + case webAddress(String) + case language(String) + case tracksOptOut(Bool) + + var stringValue: String { + switch self { + case .firstName(let value): + return value + case .lastName(let value): + return value + case .displayName(let value): + return value + case .aboutMe(let value): + return value + case .email(let value): + return value + case .emailRevertPendingChange: + return String(false) + case .primarySite(let value): + return String(value) + case .webAddress(let value): + return value + case .language(let value): + return value + case .tracksOptOut(let value): + return String(value) + } + } +} + +public typealias AccountSettingsChangeWithString = (String) -> AccountSettingsChange +public typealias AccountSettingsChangeWithInt = (Int) -> AccountSettingsChange diff --git a/WordPressKit/Sources/WordPressKit/Models/Activity.swift b/WordPressKit/Sources/WordPressKit/Models/Activity.swift new file mode 100644 index 000000000000..cd6afb3a6a58 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Activity.swift @@ -0,0 +1,310 @@ +import Foundation + +public struct Activity: Decodable { + + private enum CodingKeys: String, CodingKey { + case activityId = "activity_id" + case summary + case content + case published + case name + case type + case gridicon + case status + case isRewindable = "is_rewindable" + case rewindId = "rewind_id" + case actor + case object + case items + } + + public let activityID: String + public let summary: String + public let text: String + public let name: String + public let type: String + public let gridicon: String + public let status: String + public let rewindID: String? + public let published: Date + public let actor: ActivityActor? + public let object: ActivityObject? + public let target: ActivityObject? + public let items: [ActivityObject]? + public let content: [String: Any]? + + private let rewindable: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + guard let id = try container.decodeIfPresent(String.self, forKey: .activityId) else { + throw Error.missingActivityId + } + guard let summaryText = try container.decodeIfPresent(String.self, forKey: .summary) else { + throw Error.missingSummary + } + guard let content = try container.decodeIfPresent([String: Any].self, forKey: .content), + let contentText = content["text"] as? String else { + throw Error.missingContentText + } + guard + let publishedString = try container.decodeIfPresent(String.self, forKey: .published), + let published = Date.dateWithISO8601WithMillisecondsString(publishedString) else { + throw Error.missingPublishedDate + } + + self.activityID = id + self.summary = summaryText + self.content = content + self.text = contentText + self.published = published + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" + self.gridicon = try container.decodeIfPresent(String.self, forKey: .gridicon) ?? "" + self.status = try container.decodeIfPresent(String.self, forKey: .status) ?? "" + self.rewindable = try container.decodeIfPresent(Bool.self, forKey: .isRewindable) ?? false + self.rewindID = try container.decodeIfPresent(String.self, forKey: .rewindId) + + if let actorData = try container.decodeIfPresent([String: Any].self, forKey: .actor) { + self.actor = ActivityActor(dictionary: actorData) + } else { + self.actor = nil + } + + if let objectData = try container.decodeIfPresent([String: Any].self, forKey: .object) { + self.object = ActivityObject(dictionary: objectData) + } else { + self.object = nil + } + + if let targetData = try container.decodeIfPresent([String: Any].self, forKey: .actor) { + self.target = ActivityObject(dictionary: targetData) + } else { + self.target = nil + } + + if let orderedItems = try container.decodeIfPresent(Array.self, forKey: .items) as? [[String: Any]] { + self.items = orderedItems.map { ActivityObject(dictionary: $0) } + } else { + self.items = nil + } + } + + public var isRewindComplete: Bool { + return self.name == ActivityName.rewindComplete + } + + public var isFullBackup: Bool { + return self.name == ActivityName.fullBackup + } + + public var isRewindable: Bool { + return rewindID != nil && rewindable + } +} + +private extension Activity { + enum Error: Swift.Error { + case missingActivityId + case missingSummary + case missingContentText + case missingPublishedDate + case incorrectPusblishedDateFormat + } +} + +public struct ActivityActor { + public let displayName: String + public let type: String + public let wpcomUserID: String + public let avatarURL: String + public let role: String + + init(dictionary: [String: Any]) { + displayName = dictionary["name"] as? String ?? "" + type = dictionary["type"] as? String ?? "" + wpcomUserID = dictionary["wp_com_user_id"] as? String ?? "" + if let iconInfo = dictionary["icon"] as? [String: AnyObject] { + avatarURL = iconInfo["url"] as? String ?? "" + } else { + avatarURL = "" + } + role = dictionary["role"] as? String ?? "" + } + + public lazy var isJetpack: Bool = { + return self.type == ActivityActorType.application && + self.displayName == ActivityActorApplicationType.jetpack + }() +} + +public struct ActivityObject { + public let name: String + public let type: String + public let attributes: [String: Any] + + init(dictionary: [String: Any]) { + name = dictionary["name"] as? String ?? "" + type = dictionary["type"] as? String ?? "" + let mutableDictionary = NSMutableDictionary(dictionary: dictionary) + mutableDictionary.removeObjects(forKeys: ["name", "type"]) + if let extraAttributes = mutableDictionary as? [String: Any] { + attributes = extraAttributes + } else { + attributes = [:] + } + } +} + +public struct ActivityName { + public static let fullBackup = "rewind__backup_complete_full" + public static let rewindComplete = "rewind__complete" +} + +public struct ActivityActorType { + public static let person = "Person" + public static let application = "Application" +} + +public struct ActivityActorApplicationType { + public static let jetpack = "Jetpack" +} + +public struct ActivityStatus { + public static let error = "error" + public static let success = "success" + public static let warning = "warning" +} + +public class ActivityGroup { + public let key: String + public let name: String + public let count: Int + + init(_ groupKey: String, dictionary: [String: AnyObject]) throws { + guard let groupName = dictionary["name"] as? String else { + throw Error.missingName + } + guard let groupCount = dictionary["count"] as? Int else { + throw Error.missingCount + } + + key = groupKey + name = groupName + count = groupCount + } +} + +private extension ActivityGroup { + enum Error: Swift.Error { + case missingName + case missingCount + } +} + +public class RewindStatus { + public let state: State + public let lastUpdated: Date + public let reason: String? + public let restore: RestoreStatus? + + internal init(state: State) { + // FIXME: A hack to support free WPCom sites and Rewind. Should be obsolote as soon as the backend + // stops returning 412's for those sites. + self.state = state + self.lastUpdated = Date() + self.reason = nil + self.restore = nil + } + + init(dictionary: [String: AnyObject]) throws { + guard let rewindState = dictionary["state"] as? String else { + throw Error.missingState + } + guard let rewindStateEnum = State(rawValue: rewindState) else { + throw Error.invalidRewindState + } + guard let lastUpdatedString = dictionary["last_updated"] as? String else { + throw Error.missingLastUpdatedDate + } + guard let lastUpdatedDate = Date.dateWithISO8601WithMillisecondsString(lastUpdatedString) else { + throw Error.incorrectLastUpdatedDateFormat + } + + state = rewindStateEnum + lastUpdated = lastUpdatedDate + reason = dictionary["reason"] as? String + if let rawRestore = dictionary["rewind"] as? [String: AnyObject] { + restore = try RestoreStatus(dictionary: rawRestore) + } else { + restore = nil + } + } +} + +public extension RewindStatus { + enum State: String { + case active + case inactive + case unavailable + case awaitingCredentials = "awaiting_credentials" + case provisioning + } +} + +private extension RewindStatus { + enum Error: Swift.Error { + case missingState + case missingLastUpdatedDate + case incorrectLastUpdatedDateFormat + case invalidRewindState + } +} + +public class RestoreStatus { + public let id: String + public let status: Status + public let progress: Int + public let message: String? + public let currentEntry: String? + public let errorCode: String? + public let failureReason: String? + + init(dictionary: [String: AnyObject]) throws { + guard let restoreId = dictionary["rewind_id"] as? String else { + throw Error.missingRestoreId + } + guard let restoreStatus = dictionary["status"] as? String else { + throw Error.missingRestoreStatus + } + guard let restoreStatusEnum = Status(rawValue: restoreStatus) else { + throw Error.invalidRestoreStatus + } + + id = restoreId + status = restoreStatusEnum + progress = dictionary["progress"] as? Int ?? 0 + message = dictionary["message"] as? String + currentEntry = dictionary["current_entry"] as? String + errorCode = dictionary["error_code"] as? String + failureReason = dictionary["reason"] as? String + } +} + +public extension RestoreStatus { + enum Status: String { + case queued + case finished + case running + case fail + } +} + +extension RestoreStatus { + enum Error: Swift.Error { + case missingRestoreId + case missingRestoreStatus + case invalidRestoreStatus + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Assistant/JetpackAssistantFeatureDetails.swift b/WordPressKit/Sources/WordPressKit/Models/Assistant/JetpackAssistantFeatureDetails.swift new file mode 100644 index 000000000000..9089fada370f --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Assistant/JetpackAssistantFeatureDetails.swift @@ -0,0 +1,79 @@ +import Foundation + +public final class JetpackAssistantFeatureDetails: Codable { + public let hasFeature: Bool + /// Returns `true` if you are out of limit for the current plan. + public let isOverLimit: Bool + /// The all-time request count. + public let requestsCount: Int + /// The request limit for a free plan. + public let requestsLimit: Int + /// Contains data about the user plan. + public let currentTier: Tier? + public let usagePeriod: UsagePeriod? + public let isSiteUpdateRequired: Bool? + public let upgradeType: String? + public let upgradeURL: String? + public let nextTier: Tier? + public let tierPlans: [Tier]? + public let tierPlansEnabled: Bool? + public let costs: Costs? + + public struct Tier: Codable { + public let slug: String? + public let limit: Int + public let value: Int + public let readableLimit: String? + + enum CodingKeys: String, CodingKey { + case slug, limit, value + case readableLimit = "readable-limit" + } + } + + public struct UsagePeriod: Codable { + public let currentStart: String? + public let nextStart: String? + public let requestsCount: Int + + enum CodingKeys: String, CodingKey { + case currentStart = "current-start" + case nextStart = "next-start" + case requestsCount = "requests-count" + } + } + + public struct Costs: Codable { + public let jetpackAILogoGenerator: JetpackAILogoGenerator + public let featuredPostImage: FeaturedPostImage + + enum CodingKeys: String, CodingKey { + case jetpackAILogoGenerator = "jetpack-ai-logo-generator" + case featuredPostImage = "featured-post-image" + } + } + + public struct FeaturedPostImage: Codable { + public let image: Int + } + + public struct JetpackAILogoGenerator: Codable { + public let logo: Int + } + + enum CodingKeys: String, CodingKey { + case hasFeature = "has-feature" + case isOverLimit = "is-over-limit" + case requestsCount = "requests-count" + case requestsLimit = "requests-limit" + case usagePeriod = "usage-period" + case isSiteUpdateRequired = "site-require-upgrade" + case upgradeURL = "upgrade-url" + case upgradeType = "upgrade-type" + case currentTier = "current-tier" + case nextTier = "next-tier" + case tierPlans = "tier-plans" + case tierPlansEnabled = "tier-plans-enabled" + case costs + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Atomic/AtomicLogs.swift b/WordPressKit/Sources/WordPressKit/Models/Atomic/AtomicLogs.swift new file mode 100644 index 000000000000..bc04ca68685c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Atomic/AtomicLogs.swift @@ -0,0 +1,47 @@ +import Foundation + +public final class AtomicErrorLogEntry: Decodable { + public let message: String? + public let severity: String? + public let kind: String? + public let name: String? + public let file: String? + public let line: Int? + public let timestamp: Date? + + public enum Severity: String { + case user = "User" + case warning = "Warning" + case deprecated = "Deprecated" + case fatalError = "Fatal error" + } +} + +public final class AtomicErrorLogsResponse: Decodable { + public let totalResults: Int + public let logs: [AtomicErrorLogEntry] + public let scrollId: String? +} + +public class AtomicWebServerLogEntry: Decodable { + public let bodyBytesSent: Int? + /// The possible values are `"true"` or `"false"`. + public let cached: String? + public let date: Date? + public let httpHost: String? + public let httpReferer: String? + public let httpUserAgent: String? + public let requestTime: Double? + public let requestType: String? + public let requestUrl: String? + public let scheme: String? + public let status: Int? + public let timestamp: Int? + public let type: String? +} + +public final class AtomicWebServerLogsResponse: Decodable { + public let totalResults: Int + public let logs: [AtomicWebServerLogEntry] + public let scrollId: String? +} diff --git a/WordPressKit/Sources/WordPressKit/Models/AutomatedTransferStatus.swift b/WordPressKit/Sources/WordPressKit/Models/AutomatedTransferStatus.swift new file mode 100644 index 000000000000..e4d5c1c9e006 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/AutomatedTransferStatus.swift @@ -0,0 +1,40 @@ +import Foundation + +/// A helper object encapsulating a status of Automated Transfer operation. +public struct AutomatedTransferStatus { + public enum State: String, RawRepresentable { + case active + case backfilling + case complete + case error + case notFound = "not found" + case unknownStatus = "unknown_status" + case uploading + case pending + } + + public let status: State + public let step: Int? + public let totalSteps: Int? + + init?(status statusString: String) { + guard let status = State(rawValue: statusString) else { + return nil + } + + self.status = status + self.step = nil + self.totalSteps = nil + } + + init?(status statusString: String, step: Int, totalSteps: Int) { + guard let status = State(rawValue: statusString) else { + return nil + } + + self.status = status + self.step = step + self.totalSteps = totalSteps + } + +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaign.swift b/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaign.swift new file mode 100644 index 000000000000..be303fc499e5 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaign.swift @@ -0,0 +1,94 @@ +import Foundation + +public final class BlazeCampaign: Codable { + public let campaignID: Int + public let name: String? + public let startDate: Date? + public let endDate: Date? + /// A raw campaign status on the server. + public let status: Status + /// A subset of ``BlazeCampaign/status-swift.property`` values where some + /// cases are skipped for simplicity and mapped to other more common ones. + public let uiStatus: Status + public let budgetCents: Int? + public let targetURL: String? + public let stats: Stats? + public let contentConfig: ContentConfig? + public let creativeHTML: String? + + public init(campaignID: Int, name: String?, startDate: Date?, endDate: Date?, status: Status, uiStatus: Status, budgetCents: Int?, targetURL: String?, stats: Stats?, contentConfig: ContentConfig?, creativeHTML: String?) { + self.campaignID = campaignID + self.name = name + self.startDate = startDate + self.endDate = endDate + self.status = status + self.uiStatus = uiStatus + self.budgetCents = budgetCents + self.targetURL = targetURL + self.stats = stats + self.contentConfig = contentConfig + self.creativeHTML = creativeHTML + } + + enum CodingKeys: String, CodingKey { + case campaignID = "campaignId" + case name + case startDate + case endDate + case status + case uiStatus + case budgetCents + case targetURL = "targetUrl" + case contentConfig + case stats = "campaignStats" + case creativeHTML = "creativeHtml" + } + + public enum Status: String, Codable { + case scheduled + case created + case rejected + case approved + case active + case canceled + case finished + case processing + case unknown + + public init(from decoder: Decoder) throws { + let status = try? String(from: decoder) + self = status.flatMap(Status.init) ?? .unknown + } + } + + public struct Stats: Codable { + public let impressionsTotal: Int? + public let clicksTotal: Int? + + public init(impressionsTotal: Int?, clicksTotal: Int?) { + self.impressionsTotal = impressionsTotal + self.clicksTotal = clicksTotal + } + } + + public struct ContentConfig: Codable { + public let title: String? + public let snippet: String? + public let clickURL: String? + public let imageURL: String? + + public init(title: String?, snippet: String?, clickURL: String?, imageURL: String?) { + self.title = title + self.snippet = snippet + self.clickURL = clickURL + self.imageURL = imageURL + } + + enum CodingKeys: String, CodingKey { + case title + case snippet + case clickURL = "clickUrl" + case imageURL = "imageUrl" + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaignsSearchResponse.swift b/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaignsSearchResponse.swift new file mode 100644 index 000000000000..031652004c33 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Blaze/BlazeCampaignsSearchResponse.swift @@ -0,0 +1,15 @@ +import Foundation + +public final class BlazeCampaignsSearchResponse: Decodable { + public let campaigns: [BlazeCampaign]? + public let totalItems: Int? + public let totalPages: Int? + public let page: Int? + + public init(totalItems: Int?, campaigns: [BlazeCampaign]?, totalPages: Int?, page: Int?) { + self.totalItems = totalItems + self.campaigns = campaigns + self.totalPages = totalPages + self.page = page + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/DomainContactInformation.swift b/WordPressKit/Sources/WordPressKit/Models/DomainContactInformation.swift new file mode 100644 index 000000000000..7989b1952c03 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/DomainContactInformation.swift @@ -0,0 +1,59 @@ +import Foundation + +public struct ValidateDomainContactInformationResponse: Codable { + public struct Messages: Codable { + public var phone: [String]? + public var email: [String]? + public var postalCode: [String]? + public var countryCode: [String]? + public var city: [String]? + public var address1: [String]? + public var address2: [String]? + public var firstName: [String]? + public var lastName: [String]? + public var state: [String]? + public var organization: [String]? + } + + public var success: Bool = false + public var messages: Messages? + + /// Returns true if any of the properties within `messages` has a value. + /// + public var hasMessages: Bool { + if let messages = messages { + let mirror = Mirror(reflecting: messages) + + for child in mirror.children { + let childMirror = Mirror(reflecting: child.value) + + if childMirror.displayStyle == .optional, + let _ = childMirror.children.first { + return true + } + } + } + + return false + } + + public init() { + } +} + +public struct DomainContactInformation: Codable { + public var phone: String? + public var email: String? + public var postalCode: String? + public var countryCode: String? + public var city: String? + public var address1: String? + public var firstName: String? + public var lastName: String? + public var fax: String? + public var state: String? + public var organization: String? + + public init() { + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/EditorSettings.swift b/WordPressKit/Sources/WordPressKit/Models/EditorSettings.swift new file mode 100644 index 000000000000..2b54e5b3e960 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/EditorSettings.swift @@ -0,0 +1,58 @@ +private struct RemoteEditorSettings: Codable { + let editorMobile: String + let editorWeb: String +} + +public struct EditorSettings { + public enum Error: Swift.Error { + case decodingFailed + case unknownEditor(String) + case badRequest + case badResponse + } + + /// Editor choosen by the user to be used on Mobile + /// + /// - gutenberg: The block editor + /// - aztec: The mobile "classic" editor + /// - notSet: The user has never saved they preference on remote + public enum Mobile: String { + case gutenberg + case aztec + case notSet = "" + } + + /// Editor choosen by the user to be used on Web + /// + /// - classic: The classic editor + /// - gutenberg: The block editor + public enum Web: String { + case classic + case gutenberg + } + + public let mobile: Mobile + public let web: Web +} + +extension EditorSettings { + init(with response: Any) throws { + guard let response = response as? [String: AnyObject] else { + throw NSError(domain: NSURLErrorDomain, code: NSURLErrorBadServerResponse, userInfo: nil) + } + + let data = try JSONSerialization.data(withJSONObject: response, options: .prettyPrinted) + let editorPreferenesRemote = try JSONDecoder.apiDecoder.decode(RemoteEditorSettings.self, from: data) + try self.init(with: editorPreferenesRemote) + } + + private init(with remote: RemoteEditorSettings) throws { + guard + let mobile = Mobile(rawValue: remote.editorMobile), + let web = Web(rawValue: remote.editorWeb) + else { + throw Error.decodingFailed + } + self = EditorSettings(mobile: mobile, web: web) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Extensions/Date+endOfDay.swift b/WordPressKit/Sources/WordPressKit/Models/Extensions/Date+endOfDay.swift new file mode 100644 index 000000000000..7ce569f7881e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Extensions/Date+endOfDay.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Date { + /// Returns a Date representing the last second of the given day + /// + func endOfDay() -> Date? { + Calendar.current.date(byAdding: .second, value: 86399, to: self) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Extensions/Decodable+Dictionary.swift b/WordPressKit/Sources/WordPressKit/Models/Extensions/Decodable+Dictionary.swift new file mode 100644 index 000000000000..8559f959e83c --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Extensions/Decodable+Dictionary.swift @@ -0,0 +1,99 @@ +struct JSONCodingKeys: CodingKey { + var stringValue: String + + init?(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + + init?(intValue: Int) { + self.init(stringValue: "\(intValue)") + self.intValue = intValue + } +} + +/// Add support to decode to a Dictionary +/// From: https://stackoverflow.com/q/44603248 +extension KeyedDecodingContainer { + func decode(_ type: Dictionary.Type, forKey key: K) throws -> [String: Any] { + let container = try self.nestedContainer(keyedBy: JSONCodingKeys.self, forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Dictionary.Type, forKey key: K) throws -> [String: Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: Array.Type, forKey key: K) throws -> [Any] { + var container = try self.nestedUnkeyedContainer(forKey: key) + return try container.decode(type) + } + + func decodeIfPresent(_ type: Array.Type, forKey key: K) throws -> [Any]? { + guard contains(key) else { + return nil + } + guard try decodeNil(forKey: key) == false else { + return nil + } + return try decode(type, forKey: key) + } + + func decode(_ type: Dictionary.Type) throws -> [String: Any] { + var dictionary = [String: Any]() + + for key in allKeys { + if let boolValue = try? decode(Bool.self, forKey: key) { + dictionary[key.stringValue] = boolValue + } else if let stringValue = try? decode(String.self, forKey: key) { + dictionary[key.stringValue] = stringValue + } else if let intValue = try? decode(Int.self, forKey: key) { + dictionary[key.stringValue] = intValue + } else if let doubleValue = try? decode(Double.self, forKey: key) { + dictionary[key.stringValue] = doubleValue + } else if let nestedDictionary = try? decode(Dictionary.self, forKey: key) { + dictionary[key.stringValue] = nestedDictionary + } else if let nestedArray = try? decode(Array.self, forKey: key) { + dictionary[key.stringValue] = nestedArray + } + } + return dictionary + } +} + +extension UnkeyedDecodingContainer { + + mutating func decode(_ type: Array.Type) throws -> [Any] { + var array: [Any] = [] + while isAtEnd == false { + // See if the current value in the JSON array is `null` first and prevent infite recursion with nested arrays. + if try decodeNil() { + continue + } else if let value = try? decode(Bool.self) { + array.append(value) + } else if let value = try? decode(Double.self) { + array.append(value) + } else if let value = try? decode(String.self) { + array.append(value) + } else if let nestedDictionary = try? decode(Dictionary.self) { + array.append(nestedDictionary) + } else if var nestedContainer = try? nestedUnkeyedContainer(), let nestedArray = try? nestedContainer.decode(Array.self) { + array.append(nestedArray) + } + } + return array + } + + mutating func decode(_ type: Dictionary.Type) throws -> [String: Any] { + + let nestedContainer = try self.nestedContainer(keyedBy: JSONCodingKeys.self) + return try nestedContainer.decode(type) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Extensions/Enum+UnknownCaseRepresentable.swift b/WordPressKit/Sources/WordPressKit/Models/Extensions/Enum+UnknownCaseRepresentable.swift new file mode 100644 index 000000000000..1e36a9a6625d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Extensions/Enum+UnknownCaseRepresentable.swift @@ -0,0 +1,12 @@ +/// Allows automatic defaulting to `unknown` for any Enum that conforms to `UnknownCaseRepresentable` +/// Credits: https://www.latenightswift.com/2019/02/04/unknown-enum-cases/ +protocol UnknownCaseRepresentable: RawRepresentable, CaseIterable where RawValue: Equatable { + static var unknownCase: Self { get } +} + +extension UnknownCaseRepresentable { + public init(rawValue: RawValue) { + let value = Self.allCases.first(where: { $0.rawValue == rawValue }) + self = value ?? Self.unknownCase + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Extensions/NSAttributedString+extensions.swift b/WordPressKit/Sources/WordPressKit/Models/Extensions/NSAttributedString+extensions.swift new file mode 100644 index 000000000000..a24db85e50a3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Extensions/NSAttributedString+extensions.swift @@ -0,0 +1,32 @@ +import Foundation + +extension NSAttributedString { + /// This helper method returns a new NSAttributedString instance, with all of the the leading / trailing newLines + /// characters removed. + /// + @objc public func trimNewlines() -> NSAttributedString { + guard let trimmed = mutableCopy() as? NSMutableAttributedString else { + return self + } + + let characterSet = CharacterSet.newlines + + // Trim: Leading + var range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet) + + while range.length != 0 && range.location == 0 { + trimmed.replaceCharacters(in: range, with: String()) + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet) + } + + // Trim Trailing + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet, options: .backwards) + + while range.length != 0 && NSMaxRange(range) == trimmed.length { + trimmed.replaceCharacters(in: range, with: String()) + range = (trimmed.string as NSString).rangeOfCharacter(from: characterSet, options: .backwards) + } + + return trimmed + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Extensions/NSMutableParagraphStyle+extensions.swift b/WordPressKit/Sources/WordPressKit/Models/Extensions/NSMutableParagraphStyle+extensions.swift new file mode 100644 index 000000000000..b87a3deec5bd --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Extensions/NSMutableParagraphStyle+extensions.swift @@ -0,0 +1,15 @@ +import Foundation + +extension NSMutableParagraphStyle { + @objc convenience public init(minLineHeight: CGFloat, lineBreakMode: NSLineBreakMode, alignment: NSTextAlignment) { + self.init() + self.minimumLineHeight = minLineHeight + self.lineBreakMode = lineBreakMode + self.alignment = alignment + } + + @objc convenience public init(minLineHeight: CGFloat, maxLineHeight: CGFloat, lineBreakMode: NSLineBreakMode, alignment: NSTextAlignment) { + self.init(minLineHeight: minLineHeight, lineBreakMode: lineBreakMode, alignment: alignment) + self.maximumLineHeight = maxLineHeight + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/FeatureFlag.swift b/WordPressKit/Sources/WordPressKit/Models/FeatureFlag.swift new file mode 100644 index 000000000000..997285ebd316 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/FeatureFlag.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct FeatureFlag { + public let title: String + public let value: Bool + + public init(title: String, value: Bool) { + self.title = title + self.value = value + } +} + +// Codable Conformance is used to create mock objects in testing +extension FeatureFlag: Codable { + + struct DynamicKey: CodingKey { + var stringValue: String + init(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? + + init(intValue: Int) { + self.intValue = intValue + self.stringValue = "\(intValue)" + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + try container.encode(self.value, forKey: DynamicKey(stringValue: self.title)) + } +} + +/// Comparable Conformance is used to compare objects in testing, and to provide stable `FeatureFlagList` ordering +extension FeatureFlag: Comparable { + public static func < (lhs: FeatureFlag, rhs: FeatureFlag) -> Bool { + lhs.title < rhs.title + } +} + +public typealias FeatureFlagList = [FeatureFlag] + +extension FeatureFlagList { + public var dictionaryValue: [String: Bool] { + self.reduce(into: [:]) { + $0[$1.title] = $1.value + } + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackCredentials.swift b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackCredentials.swift new file mode 100644 index 000000000000..76ac36233e19 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackCredentials.swift @@ -0,0 +1,12 @@ +import Foundation + +/// A limited representation of the users Jetpack credentials +public struct JetpackScanCredentials: Decodable { + public let host: String? + public let port: Int? + public let user: String? + public let path: String? + public let type: String + public let role: String + public let stillValid: Bool +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScan.swift b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScan.swift new file mode 100644 index 000000000000..8ac50911946a --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScan.swift @@ -0,0 +1,75 @@ +import Foundation + +public struct JetpackScan: Decodable { + public enum JetpackScanState: String, Decodable, UnknownCaseRepresentable { + case idle + case scanning + case unavailable + case provisioning + + // Internal states that don't come from the server + + // The scan will be in this state when its in the process of fixing any fixable threats + case fixingThreats + + case unknown + static let unknownCase: Self = .unknown + } + + /// Whether the scan feature is available or not + public var isEnabled: Bool { + return (state != .unavailable) && (state != .unknown) + } + + /// The state of the current scan + public var state: JetpackScanState + + /// If a scan is in an unavailable state, this will return the reason + public var reason: String? + + /// If there is a scan in progress, this will return its status + public var current: JetpackScanStatus? + + /// Scan Status for the most recent scan + /// This will be nil if there is currently a scan taking place + public var mostRecent: JetpackScanStatus? + + /// An array of the current threats + /// During a scan this will return the previous scans threats + public var threats: [JetpackScanThreat]? + + /// A limited representation of the users credientals for each role + public var credentials: [JetpackScanCredentials]? + + /// Internal var that doesn't come from the server + /// An array of the threats being fixed current + public var threatFixStatus: [JetpackThreatFixStatus]? + + // MARK: - Private: Decodable + private enum CodingKeys: String, CodingKey { + case mostRecent, state, reason, current, threats, credentials + } +} + +// MARK: - JetpackScanStatus +public struct JetpackScanStatus: Decodable { + public var isInitial: Bool + + /// The date the scan started + public var startDate: Date? + + /// The progress of the scan from 0 - 100 + public var progress: Int + + /// How long the scan took / is taking + public var duration: TimeInterval? + + /// If there was an error finishing the scan + /// This will only be available for past scans + public var didFail: Bool? + + private enum CodingKeys: String, CodingKey { + case startDate = "timestamp", didFail = "error" + case duration, progress, isInitial + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanHistory.swift b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanHistory.swift new file mode 100644 index 000000000000..3074ad82e9dc --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanHistory.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct JetpackScanHistory: Decodable { + public let threats: [JetpackScanThreat] + public let lifetimeStats: JetpackScanHistoryStats +} + +public struct JetpackScanHistoryStats: Decodable { + public let scans: Int? + public let threatsFound: Int? + public let threatsResolved: Int? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + scans = Self.decode(in: container, forKey: .scans) + threatsFound = Self.decode(in: container, forKey: .threatsFound) + threatsResolved = Self.decode(in: container, forKey: .threatsResolved) + } + + /// Special handling of the decoding since it could be a string or an int + private static func decode(in container: KeyedDecodingContainer, forKey key: CodingKeys) -> Int? { + var intVal: Int? + if let stringVal = try? container.decode(String.self, forKey: key) { + intVal = Int(stringVal) + } else if let val = try? container.decode(Int.self, forKey: key) { + intVal = val + } + + guard let value = intVal else { + return nil + } + + return value < 0 ? nil : value + } + + private enum CodingKeys: String, CodingKey { + case scans, threatsFound, threatsResolved + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanThreat.swift b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanThreat.swift new file mode 100644 index 000000000000..5bba1eb0750e --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackScanThreat.swift @@ -0,0 +1,256 @@ +import Foundation + +public struct JetpackScanThreat: Decodable { + public enum ThreatStatus: String, Decodable, UnknownCaseRepresentable { + case fixed + case ignored + case current + + // Internal states + case fixing + + case unknown + static let unknownCase: Self = .unknown + } + + public enum ThreatType: String, UnknownCaseRepresentable { + case core + case file + case plugin + case theme + case database + + case unknown + static let unknownCase: Self = .unknown + + init(threat: JetpackScanThreat) { + // Logic used from https://github.com/Automattic/wp-calypso/blob/5a6b257ad97b361fa6f6a6e496cbfc0ef952b921/client/components/jetpack/threat-item/utils.ts#L11 + if threat.diff != nil { + self = .core + } else if threat.context != nil { + self = .file + } else if let ext = threat.extension { + self = ThreatType(rawValue: ext.type.rawValue) + } else if threat.rows != nil || threat.signature.contains(Constants.databaseSignature) { + self = .database + } else { + self = .unknown + } + } + + private struct Constants { + static let databaseSignature = "Suspicious.Links" + } + } + + /// The ID of the threat + public let id: Int + + /// The name of the threat signature + public let signature: String + + /// The description of the threat signature + public let description: String + + /// The date the threat was first detected + public let firstDetected: Date + + /// Whether the threat can be automatically fixed + public let fixable: JetpackScanThreatFixer? + + /// The filename + public let fileName: String? + + /// The status of the threat (fixed, ignored, current) + public var status: ThreatStatus? + + /// The date the threat was fixed on + public let fixedOn: Date? + + /// More information if the threat is a extension type (plugin or theme) + public let `extension`: JetpackThreatExtension? + + /// The type of threat this is + public private(set) var type: ThreatType = .unknown + + /// If this is a file based threat this will provide code context to be displayed + /// Context example: + /// 3: start test + /// 4: VIRUS_SIG + /// 5: end test + /// marks: 4: (0, 9) + public let context: JetpackThreatContext? + + // Core modification threats will contain a git diff string + public let diff: String? + + // Database threats will contain row information + public let rows: [String: Any]? + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(Int.self, forKey: .id) + signature = try container.decode(String.self, forKey: .signature) + fileName = try container.decodeIfPresent(String.self, forKey: .fileName) + description = try container.decode(String.self, forKey: .description) + firstDetected = try container.decode(Date.self, forKey: .firstDetected) + fixedOn = try container.decodeIfPresent(Date.self, forKey: .fixedOn) + fixable = try? container.decodeIfPresent(JetpackScanThreatFixer.self, forKey: .fixable) ?? nil + `extension` = try container.decodeIfPresent(JetpackThreatExtension.self, forKey: .extension) + diff = try container.decodeIfPresent(String.self, forKey: .diff) + rows = try container.decodeIfPresent([String: Any].self, forKey: .rows) + status = try container.decode(ThreatStatus.self, forKey: .status) + + // Context obj can either be: + // - not present + // - a dictionary + // - an empty string + // we can not just set to nil because the threat type logic needs to know if the + // context attr was present or not + if let contextDict = try? container.decodeIfPresent([String: Any].self, forKey: .context) { + context = JetpackThreatContext(with: contextDict) + } else if (try container.decodeIfPresent(String.self, forKey: .context)) != nil { + context = JetpackThreatContext.emptyObject() + } else { + context = nil + } + + // Calculate the type of threat last + type = ThreatType(threat: self) + } + + private enum CodingKeys: String, CodingKey { + case fileName = "filename" + case firstDetected, fixedOn + case id, signature, description, fixable + case `extension`, diff, context, rows, status + } +} + +/// An object that describes how a threat can be fixed +public struct JetpackScanThreatFixer: Decodable { + public enum ThreatFixType: String, Decodable, UnknownCaseRepresentable { + case replace + case delete + case update + case edit + case rollback + + case unknown + static let unknownCase: Self = .unknown + } + + /// The suggested threat fix type + public let type: ThreatFixType + + /// The file path of the file to be fixed + public var file: String? + + /// The target version to fix to + public var target: String? + + private enum CodingKeys: String, CodingKey { + case type = "fixer", file, target + } +} + +/// Represents plugin or theme additional metadata +public struct JetpackThreatExtension: Decodable { + public enum JetpackThreatExtensionType: String, Decodable, UnknownCaseRepresentable { + case plugin + case theme + + case unknown + static let unknownCase: Self = .unknown + } + + public let slug: String + public let name: String + public let type: JetpackThreatExtensionType + public let isPremium: Bool + public let version: String +} + +public struct JetpackThreatContext { + public struct ThreatContextLine { + public var lineNumber: Int + public var contents: String + public var highlights: [NSRange]? + } + + public let lines: [ThreatContextLine] + + public static func emptyObject() -> JetpackThreatContext { + return JetpackThreatContext(with: []) + } + + public init(with lines: [ThreatContextLine]) { + self.lines = lines + } + + public init?(with dict: [String: Any]) { + guard let marksDict = dict["marks"] as? [String: Any] else { + return nil + } + + var lines: [ThreatContextLine] = [] + + // Parse the "lines" which are represented as the keys of the dict + // "3", "4", "5" + for key in dict.keys { + // Since we've already pulled the marks out above, ignore it here + if key == "marks" { + continue + } + + // Validate the incoming object to make sure it contains an integer line, and + // the string contents + guard let lineNum = Int(key), let contents = dict[key] as? String else { + continue + } + + let highlights: [NSRange]? = Self.highlights(with: marksDict, for: key) + + let context = ThreatContextLine(lineNumber: lineNum, + contents: contents, + highlights: highlights) + + lines.append(context) + } + + // Since the dictionary keys are unsorted, resort by line number + self.lines = lines.sorted { $0.lineNumber < $1.lineNumber } + } + + /// Parses the marks dictionary and converts them to an array of NSRange's + private static func highlights(with dict: [String: Any], for key: String) -> [NSRange]? { + guard let marks = dict[key] as? [[Double]] else { + return nil + } + + var highlights: [NSRange] = [] + + for rangeArray in marks { + if let range = Self.range(with: rangeArray) { + highlights.append(range) + } + } + + return (highlights.count > 0) ? highlights : nil + } + + /// Generates an NSRange from an array + /// - Parameter array: An array that contains 2 numbers [start, length] + /// - Returns: Nil if the array fails validation, or an NSRange + private static func range(with array: [Double]) -> NSRange? { + guard array.count == 2, + let location = array.first, + let length = array.last + else { + return nil + } + + return NSRange(location: Int(location), length: Int(length - location)) + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackThreatFixStatus.swift b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackThreatFixStatus.swift new file mode 100644 index 000000000000..e3d3877283d9 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Jetpack Scan/JetpackThreatFixStatus.swift @@ -0,0 +1,79 @@ +import Foundation + +public struct JetpackThreatFixResponse: Decodable { + public let success: Bool + public let threats: [JetpackThreatFixStatus] + + public let isFixingThreats: Bool + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + success = try container.decode(Bool.self, forKey: .success) + + let statusDict = try container.decode([String: [String: String]].self, forKey: .threats) + var statusArray: [JetpackThreatFixStatus] = [] + + for (threatId, status) in statusDict { + guard let id = Int(threatId), let statusValue = status["status"] else { + throw ResponseError.decodingFailure + } + + let fixStatus = JetpackThreatFixStatus(with: id, status: statusValue) + statusArray.append(fixStatus) + } + + isFixingThreats = statusArray.filter { $0.status == .inProgress }.count > 0 + threats = statusArray + } + + /// Returns true the fixing status is complete, and all threats are no longer in progress + public var finished: Bool { + return inProgress.count <= 0 + } + + /// Returns all the fixed threats + public var fixed: [JetpackThreatFixStatus] { + return threats.filter { $0.status == .fixed } + } + + /// Returns all the in progress threats + public var inProgress: [JetpackThreatFixStatus] { + return threats.filter { $0.status == .inProgress } + } + + private enum CodingKeys: String, CodingKey { + case success = "ok", threats + } + + enum ResponseError: Error { + case decodingFailure + } +} + +public struct JetpackThreatFixStatus { + public enum ThreatFixStatus: String, Decodable, UnknownCaseRepresentable { + case notStarted = "not_started" + case inProgress = "in_progress" + case fixed + + case unknown + static let unknownCase: Self = .unknown + } + + public let threatId: Int + public let status: ThreatFixStatus + + public var threat: JetpackScanThreat? + + public init(with threatId: Int, status: String) { + self.threatId = threatId + self.status = ThreatFixStatus(rawValue: status) + } + + public init(with threat: JetpackScanThreat, status: ThreatFixStatus = .inProgress) { + self.threat = threat + self.threatId = threat.id + self.status = status + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/JetpackBackup.swift b/WordPressKit/Sources/WordPressKit/Models/JetpackBackup.swift new file mode 100644 index 000000000000..2b8b0832caf3 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/JetpackBackup.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct JetpackBackup: Decodable { + + // Common + public let backupPoint: Date + public let downloadID: Int + public let rewindID: String + public let startedAt: Date + + // Prepare backup + public let progress: Int? + + // Get backup status + public let downloadCount: Int? + public let url: String? + public let validUntil: Date? + + private enum CodingKeys: String, CodingKey { + case backupPoint + case downloadID = "downloadId" + case rewindID = "rewindId" + case startedAt + case progress + case downloadCount + case url + case validUntil + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/JetpackRestoreTypes.swift b/WordPressKit/Sources/WordPressKit/Models/JetpackRestoreTypes.swift new file mode 100644 index 000000000000..2ade0637263d --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/JetpackRestoreTypes.swift @@ -0,0 +1,35 @@ +import Foundation + +public struct JetpackRestoreTypes { + public var themes: Bool + public var plugins: Bool + public var uploads: Bool + public var sqls: Bool + public var roots: Bool + public var contents: Bool + + public init(themes: Bool = true, + plugins: Bool = true, + uploads: Bool = true, + sqls: Bool = true, + roots: Bool = true, + contents: Bool = true) { + self.themes = themes + self.plugins = plugins + self.uploads = uploads + self.sqls = sqls + self.roots = roots + self.contents = contents + } + + func toDictionary() -> [String: AnyObject] { + return [ + "themes": themes as AnyObject, + "plugins": plugins as AnyObject, + "uploads": uploads as AnyObject, + "sqls": sqls as AnyObject, + "roots": roots as AnyObject, + "contents": contents as AnyObject + ] + } +} diff --git a/WordPressKit/Sources/WordPressKit/Models/KeyringConnection.swift b/WordPressKit/Sources/WordPressKit/Models/KeyringConnection.swift new file mode 100644 index 000000000000..22656b33fdb4 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/KeyringConnection.swift @@ -0,0 +1,23 @@ +import Foundation + +/// KeyringConnection represents a keyring connected to a particular external service. +/// We only rarely need keyring data and we don't really need to persist it. For these +/// reasons KeyringConnection is treated like a model, even though it is not an NSManagedObject, +/// but also treated like it is a Remote Object. +/// +open class KeyringConnection: NSObject { + @objc open var additionalExternalUsers = [KeyringConnectionExternalUser]() + @objc open var dateIssued = Date() + @objc open var dateExpires: Date? + @objc open var externalID = "" // Some services uses strings for their IDs + @objc open var externalName = "" + @objc open var externalDisplay = "" + @objc open var externalProfilePicture = "" + @objc open var label = "" + @objc open var keyringID: NSNumber = 0 + @objc open var refreshURL = "" + @objc open var service = "" + @objc open var status = "" + @objc open var type = "" + @objc open var userID: NSNumber = 0 +} diff --git a/WordPressKit/Sources/WordPressKit/Models/KeyringConnectionExternalUser.swift b/WordPressKit/Sources/WordPressKit/Models/KeyringConnectionExternalUser.swift new file mode 100644 index 000000000000..72a10467acbf --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/KeyringConnectionExternalUser.swift @@ -0,0 +1,11 @@ +import Foundation + +/// KeyringConnectionExternalUser represents an additional user account on the +/// external service that could be used other than the default account. +/// +open class KeyringConnectionExternalUser: NSObject { + @objc open var externalID = "" + @objc open var externalName = "" + @objc open var externalCategory = "" + @objc open var externalProfilePicture = "" +} diff --git a/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryEntry.swift b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryEntry.swift new file mode 100644 index 000000000000..e377de162088 --- /dev/null +++ b/WordPressKit/Sources/WordPressKit/Models/Plugins/PluginDirectoryEntry.swift @@ -0,0 +1,294 @@ +import Foundation + +public struct PluginDirectoryEntry { + public let name: String + public let slug: String + public let version: String? + public let lastUpdated: Date? + + public let icon: URL? + public let banner: URL? + + public let author: String + public let authorURL: URL? + + let descriptionHTML: String? + let installationHTML: String? + let faqHTML: String? + let changelogHTML: String? + + public var descriptionText: NSAttributedString? { + return extractHTMLText(self.descriptionHTML) + } + public var installationText: NSAttributedString? { + return extractHTMLText(self.installationHTML) + } + public var faqText: NSAttributedString? { + return extractHTMLText(self.faqHTML) + } + public var changelogText: NSAttributedString? { + return extractHTMLText(self.changelogHTML) + } + + let rating: Double + public var starRating: Double { + return (rating / 10).rounded() / 2 + // rounded to nearest half. + } +} + +extension PluginDirectoryEntry: Equatable { + public static func ==(lhs: PluginDirectoryEntry, rhs: PluginDirectoryEntry) -> Bool { + return lhs.name == rhs.name + && lhs.slug == rhs.slug + && lhs.version == rhs.version + && lhs.lastUpdated == rhs.lastUpdated + && lhs.icon == rhs.icon + } +} + +extension PluginDirectoryEntry: Codable { + private enum CodingKeys: String, CodingKey { + case name + case slug + case version + case lastUpdated = "last_updated" + case icons + case author + case rating + + case banners + + case sections + } + + private enum BannersKeys: String, CodingKey { + case high + case low + } + + private enum SectionKeys: String, CodingKey { + case description + case installation + case faq + case changelog + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let decodedName = try container.decode(String.self, forKey: .name) + name = decodedName.stringByDecodingXMLCharacters() + slug = try container.decode(String.self, forKey: .slug) + version = try? container.decode(String.self, forKey: .version) + lastUpdated = try? container.decode(Date.self, forKey: .lastUpdated) + rating = try container.decode(Double.self, forKey: .rating) + + let icons = try? container.decodeIfPresent([String: String].self, forKey: .icons) + icon = icons?["2x"].flatMap({ (s) -> URL? in + URL(string: s) + }) + + // If there's no hi-res version of the banner, the API returns `high: false`, instead of something more logical, + // like an empty string or `null`, hence the dance below. + let banners = try? container.nestedContainer(keyedBy: BannersKeys.self, forKey: .banners) + + if let highRes = try? banners?.decodeIfPresent(String.self, forKey: .high) { + banner = URL(string: highRes) + } else if let lowRes = try? banners?.decodeIfPresent(String.self, forKey: .low) { + banner = URL(string: lowRes) + } else { + banner = nil + } + + (author, authorURL) = try extractAuthor(container.decode(String.self, forKey: .author)) + + let sections = try? container.nestedContainer(keyedBy: SectionKeys.self, forKey: .sections) + + descriptionHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .description)) + installationHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .installation)) + faqHTML = try trimTags(sections?.decodeIfPresent(String.self, forKey: .faq)) + + let changelog = try sections?.decodeIfPresent(String.self, forKey: .changelog) + changelogHTML = trimTags(trimChangelog(changelog)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name.stringByEncodingXMLCharacters(), forKey: .name) + try container.encode(slug, forKey: .slug) + try container.encodeIfPresent(version, forKey: .version) + try container.encodeIfPresent(lastUpdated, forKey: .lastUpdated) + try container.encode(rating, forKey: .rating) + + if icon != nil { + try container.encode(["2x": icon], forKey: .icons) + } + + if banner != nil { + try container.encode([BannersKeys.high.rawValue: banner], forKey: .banners) + } + + if let url = authorURL { + try container.encode("\(author)", forKey: .author) + } else { + try container.encode(author, forKey: .author) + } + + let sections: [String: String] = [SectionKeys.changelog: changelogHTML, + SectionKeys.description: descriptionHTML, + SectionKeys.faq: faqHTML, + SectionKeys.installation: installationHTML].reduce([:]) { + var newValue = $0 + if let value = $1.value { + newValue[$1.key.rawValue] = value + } + return newValue + } + + try container.encode(sections, forKey: .sections) + } + + internal init(responseObject: [String: AnyObject]) throws { + // Data returned by the featured plugins API endpoint is almost exactly in the same format + // as the data from the Plugin Directory, with few fields missing (updateDate, version, etc). + // In order to avoid duplicating almost identical entites, we provide a special initializer + // that `nil`s out those fields. + + guard let name = responseObject["name"] as? String, + let slug = responseObject["slug"] as? String, + let authorString = responseObject["author"] as? String, + let rating = responseObject["rating"] as? Double else { + throw PluginServiceRemote.ResponseError.decodingFailure + } + + self.name = name + self.slug = slug + self.rating = rating + self.author = extractAuthor(authorString).name + + if let icon = (responseObject["icons"]?["2x"] as? String).flatMap({ URL(string: $0) }) { + self.icon = icon + } else { + self.icon = (responseObject["icons"]?["1x"] as? String).flatMap { URL(string: $0) } + } + + self.authorURL = nil + self.version = nil + self.lastUpdated = nil + self.banner = nil + self.descriptionHTML = nil + self.installationHTML = nil + self.faqHTML = nil + self.changelogHTML = nil + } +} + +// Since the WPOrg API returns `author` as a HTML string (or freeform text), we need to get ugly and parse out the important bits out of it ourselves. +// Using the built-in NSAttributedString API for it is too slow — it's required to run on main thread and it calls out to WebKit APIs, +// making the context switches excessively expensive when trying to display a list of plugins. +typealias Author = (name: String, link: URL?) + +internal func extractAuthor(_ author: String) -> Author { + // Because the `author` field is so free-form, there's cases of it being + // * regular string ("Gutenberg") + // * URL ("https://wordpress.org/plugins/gutenberg/#reviews&arg=1") + // * HTML link ("Gutenberg" + // but also fun things like + // * malformed HTML: "Gutenberg". + // To save ourselves a headache of trying to support all those edge-cases when parsing out the + // user-facing name, let's just employ honest to god XMLParser and be done with it. + // (h/t @koke for suggesting and writing the XMLParser approach) + + guard let data = author + .replacingOccurrences(of: "&", with: "&") // can't have naked "&" in XML, but they're valid in URLs. + .data(using: .utf8) else { + return (author, nil) + } + + let parser = XMLParser(data: data) + let delegate = AuthorParser() + + parser.delegate = delegate + + guard parser.parse() else { + if let url = URL(string: author), + url.scheme != nil { + return (author, url) + } else { + return (author, nil) + } + } + + return (delegate.author, delegate.url) +} + +internal func extractHTMLText(_ text: String?) -> NSAttributedString? { + guard Thread.isMainThread, + let data = text?.data(using: .utf16), + let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) else { + return nil + } + + return attributedString +} + +internal func trimTags(_ htmlString: String?) -> String? { + // Because the HTML we get from backend can contain literally anything, we need to set some limits as to what we won't even try to parse. + let tagsToRemove = ["script", "iframe"] + + guard var html = htmlString else { return nil } + + for tag in tagsToRemove { + let openingTag = "<\(tag)" + let closingTag = "/\(tag)>" + + if let openingRange = html.range(of: openingTag), + let closingRange = html.range(of: closingTag) { + + let rangeToRemove = openingRange.lowerBound.. String? { + // The changelog that some plugins return is HUGE — Gutenberg as of 2.0 for example returns over 50KiB of text and 1000s of lines, + // Akismet has changelog going back to 2009, etc — there isn't any backend-enforced limit, but thankfully there is backend-enforced structure. + // Showing more than last versions rel-notes seems somewhat poinless (and unlike how, e.g. App Store works), so we trim it to just the + // latest version here. If the user wants to see the whole thing, they can open the plugin's page in WPOrg directory in a browser. + guard let log = changelog else { return nil } + + guard let firstOccurence = log.range(of: "