From 7ced1b4a1ab2d6950c908762d4cedca984237db0 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 25 Mar 2026 23:30:19 +1300 Subject: [PATCH 1/3] Unify publicize connection identifiers to use connectionID Replace the dual keyringConnectionID/connectionID system with plain connectionID throughout the publicize social sharing code. The new wpcom/v2 endpoint no longer provides keyring_connection_id, and the server-side skip metadata uses connectionID anyway. --- Sources/WordPressData/Swift/Post.swift | 78 ++------ .../Models/Post+JetpackSocialTests.swift | 186 +++--------------- .../PostHelperJetpackSocialTests.swift | 30 +-- .../Services/PostHelper+JetpackSocial.swift | 53 ++--- .../Post/PostEditor+JetpackSocial.swift | 2 +- .../PostSettings/PostSettingsViewModel.swift | 4 +- ...blishingSocialAccountsViewController.swift | 16 +- 7 files changed, 88 insertions(+), 281 deletions(-) diff --git a/Sources/WordPressData/Swift/Post.swift b/Sources/WordPressData/Swift/Post.swift index 46a0621b1e5b..77d61df59e6f 100644 --- a/Sources/WordPressData/Swift/Post.swift +++ b/Sources/WordPressData/Swift/Post.swift @@ -99,79 +99,29 @@ public class Post: AbstractPost { // MARK: - PublicizeConnections - @objc public func publicizeConnectionDisabledForKeyringID(_ keyringID: NSNumber) -> Bool { - let isKeyringEntryDisabled = disabledPublicizeConnections?[keyringID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue - - // try to check in case there's an entry for the PublicizeConnection that's keyed by the connectionID. - guard let connections = blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), - let existingValue = disabledPublicizeConnections?[connection.connectionID]?[Constants.publicizeValueKey] else { - // fall back to keyringID if there is no such entry with the connectionID. - return isKeyringEntryDisabled - } - - let isConnectionEntryDisabled = existingValue == Constants.publicizeDisabledValue - return isConnectionEntryDisabled || isKeyringEntryDisabled - } - - public func enablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { - // if there's another entry keyed by connectionID references to the same connection, - // we need to make sure that the values are kept in sync. - if let connections = blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }), - let _ = disabledPublicizeConnections?[connection.connectionID] { - enablePublicizeConnection(keyedBy: connection.connectionID) - } - - enablePublicizeConnection(keyedBy: keyringID) - } - - public func disablePublicizeConnectionWithKeyringID(_ keyringID: NSNumber) { - // if there's another entry keyed by connectionID references to the same connection, - // we need to make sure that the values are kept in sync. - if let connections = blog.connections, - let connectionID = connections.first(where: { $0.keyringConnectionID == keyringID })?.connectionID, - let _ = disabledPublicizeConnections?[connectionID] { - disablePublicizeConnection(keyedBy: connectionID) - - // additionally, if the keyring entry doesn't exist, there's no need create both formats. - // we can just update the dictionary's key from connectionID to keyringID instead. - if disabledPublicizeConnections?[keyringID] == nil, - let updatedEntry = disabledPublicizeConnections?[connectionID] { - disabledPublicizeConnections?.removeValue(forKey: connectionID) - disabledPublicizeConnections?[keyringID] = updatedEntry - return - } - } - - disablePublicizeConnection(keyedBy: keyringID) + @objc public func publicizeConnectionDisabled(forConnectionID connectionID: NSNumber) -> Bool { + disabledPublicizeConnections?[connectionID]?[Constants.publicizeValueKey] == Constants.publicizeDisabledValue } - /// Marks the Publicize connection with the given id as enabled. - /// - /// - Parameter id: The dictionary key for `disabledPublicizeConnections`. - private func enablePublicizeConnection(keyedBy id: NSNumber) { - guard var connection = disabledPublicizeConnections?[id] else { + @objc public func enablePublicizeConnection(forConnectionID connectionID: NSNumber) { + guard var entry = disabledPublicizeConnections?[connectionID] else { return } - // if the auto-sharing settings is not yet synced to remote, - // we can just remove the entry since all connections are enabled by default. - guard let _ = connection[Constants.publicizeIdKey] else { - _ = disabledPublicizeConnections?.removeValue(forKey: id) + // If the entry hasn't been synced to remote yet, + // remove it since all connections are enabled by default. + guard let _ = entry[Constants.publicizeIdKey] else { + _ = disabledPublicizeConnections?.removeValue(forKey: connectionID) return } - connection[Constants.publicizeValueKey] = Constants.publicizeEnabledValue - disabledPublicizeConnections?[id] = connection + entry[Constants.publicizeValueKey] = Constants.publicizeEnabledValue + disabledPublicizeConnections?[connectionID] = entry } - /// Marks the Publicize connection with the given id as disabled. - /// - /// - Parameter id: The dictionary key for `disabledPublicizeConnections`. - private func disablePublicizeConnection(keyedBy id: NSNumber) { - if let _ = disabledPublicizeConnections?[id] { - disabledPublicizeConnections?[id]?[Constants.publicizeValueKey] = Constants.publicizeDisabledValue + @objc public func disablePublicizeConnection(forConnectionID connectionID: NSNumber) { + if disabledPublicizeConnections?[connectionID] != nil { + disabledPublicizeConnections?[connectionID]?[Constants.publicizeValueKey] = Constants.publicizeDisabledValue return } @@ -179,7 +129,7 @@ public class Post: AbstractPost { disabledPublicizeConnections = [NSNumber: [String: String]]() } - disabledPublicizeConnections?[id] = [Constants.publicizeValueKey: Constants.publicizeDisabledValue] + disabledPublicizeConnections?[connectionID] = [Constants.publicizeValueKey: Constants.publicizeDisabledValue] } // MARK: - Comments diff --git a/Tests/KeystoneTests/Tests/Models/Post+JetpackSocialTests.swift b/Tests/KeystoneTests/Tests/Models/Post+JetpackSocialTests.swift index 5252ddeb5a46..7b7f1853b7d2 100644 --- a/Tests/KeystoneTests/Tests/Models/Post+JetpackSocialTests.swift +++ b/Tests/KeystoneTests/Tests/Models/Post+JetpackSocialTests.swift @@ -27,245 +27,119 @@ class Post_JetpackSocialTests: CoreDataTestCase { // MARK: - Checking for PublicizeConnection state - func testCheckPublicizeConnectionHavingOnlyKeyringIDEntry() { + func testCheckPublicizeConnectionDisabled() { // Given - let keyringID = NSNumber(value: 100) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled] - ]) - - // When - let result = post.publicizeConnectionDisabledForKeyringID(keyringID) - - // Then - XCTAssertTrue(result) - } - - func testCheckPublicizeConnectionHavingOnlyConnectionIDEntry() { - // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) let post = makePost(disabledConnections: [ connectionID: [.valueKey: .disabled] ]) // When - let result = post.publicizeConnectionDisabledForKeyringID(keyringID) + let result = post.publicizeConnectionDisabled(forConnectionID: connectionID) // Then XCTAssertTrue(result) } - func testCheckPublicizeConnectionHavingDifferentKeyringAndConnectionEntries() { + func testCheckPublicizeConnectionNotDisabled() { // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .enabled], - connectionID: [.valueKey: .disabled] - ]) - let post2 = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled], connectionID: [.valueKey: .enabled] ]) // When - let result1 = post.publicizeConnectionDisabledForKeyringID(keyringID) - let result2 = post2.publicizeConnectionDisabledForKeyringID(keyringID) + let result = post.publicizeConnectionDisabled(forConnectionID: connectionID) // Then - // if either one of the value is true, then the method should return true. See: pctCYC-XT-p2#comment-1000 - XCTAssertTrue(result1) - XCTAssertTrue(result2) + XCTAssertFalse(result) } - // MARK: - Disabling connections - - func testDisableConnectionWithoutAnyEntries() throws { + func testCheckPublicizeConnectionWithNoEntry() { // Given - let keyringID = NSNumber(value: 100) + let connectionID = NSNumber(value: 200) let post = makePost() // When - post.disablePublicizeConnectionWithKeyringID(keyringID) + let result = post.publicizeConnectionDisabled(forConnectionID: connectionID) // Then - let entry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(entry[.valueKey], .disabled) + XCTAssertFalse(result) } - func testDisableConnectionWithPriorKeyringEntry() throws { - // Given - let keyringID = NSNumber(value: 100) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .enabled] - ]) - - // When - post.disablePublicizeConnectionWithKeyringID(keyringID) - - // Then - let entry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(entry[.valueKey], .disabled) - } + // MARK: - Disabling connections - func testDisableConnectionWithPriorKeyringAndConnectionEntries() throws { + func testDisableConnectionWithoutAnyEntries() throws { // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .enabled], - connectionID: [.valueKey: .enabled] - ]) + let post = makePost() // When - post.disablePublicizeConnectionWithKeyringID(keyringID) + post.disablePublicizeConnection(forConnectionID: connectionID) // Then - // both entries' values should be updated. - let keyringEntry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(keyringEntry[.valueKey], .disabled) - - let connectionEntry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) - XCTAssertEqual(connectionEntry[.valueKey], .disabled) + let entry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) + XCTAssertEqual(entry[.valueKey], .disabled) } - func testDisableConnectionHavingOnlyConnectionIDEntry() throws { + func testDisableConnectionWithPriorEntry() throws { // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) let post = makePost(disabledConnections: [ connectionID: [.valueKey: .enabled] ]) // When - post.disablePublicizeConnectionWithKeyringID(keyringID) + post.disablePublicizeConnection(forConnectionID: connectionID) // Then - // if the keyring entry doesn't exist, the dictionary key should be updated to the keyringID. - let keyringEntry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(keyringEntry[.valueKey], .disabled) - - // the connection entry should be deleted. - XCTAssertNil(post.disabledPublicizeConnections?[connectionID]) + let entry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) + XCTAssertEqual(entry[.valueKey], .disabled) } // MARK: - Enabling connections - // Note: unlikely case since there must be an entry in the `disabledPublicizeConnections` for the switch to be on. func testEnableConnectionWithoutAnyEntries() { // Given - let keyringID = NSNumber(value: 100) + let connectionID = NSNumber(value: 200) let post = makePost() // When - post.enablePublicizeConnectionWithKeyringID(keyringID) + post.enablePublicizeConnection(forConnectionID: connectionID) // Then // Calling the enable method should do nothing. - XCTAssertNil(post.disabledPublicizeConnections?[keyringID]) - } - - func testEnableConnectionWithLocalKeyringEntry() { - // Given - let keyringID = NSNumber(value: 100) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled] - ]) - - // When - post.enablePublicizeConnectionWithKeyringID(keyringID) - - // Then - // if the entry hasn't been synced yet, the entry will be deleted since all connections are enabled by default. - XCTAssertNil(post.disabledPublicizeConnections?[keyringID]) - } - - func testEnableConnectionWithSyncedKeyringEntry() throws { - // Given - let keyringID = NSNumber(value: 100) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled, .idKey: "24"] // having an id means the entry exists on backend. - ]) - - // When - post.enablePublicizeConnectionWithKeyringID(keyringID) - - // Then - let keyringEntry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(keyringEntry[.valueKey], .enabled) - } - - // both keyring entry and connection entry are synced - func testEnableConnectionWithPriorSyncedKeyringAndConnectionEntries() throws { - // Given - let keyringID = NSNumber(value: 100) - let connectionID = NSNumber(value: 200) - let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled, .idKey: "24"], // having an id means the entry exists on backend. - connectionID: [.valueKey: .disabled, .idKey: "26"] - ]) - - // When - post.enablePublicizeConnectionWithKeyringID(keyringID) - - // Then - // both entries should be updated. - let keyringEntry = try XCTUnwrap(post.disabledPublicizeConnections?[keyringID]) - XCTAssertEqual(keyringEntry[.valueKey], .enabled) - - let connectionEntry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) - XCTAssertEqual(connectionEntry[.valueKey], .enabled) + XCTAssertNil(post.disabledPublicizeConnections?[connectionID]) } - // the keyring entry is local, but the connection entry is synced. - func testEnableConnectionWithPriorLocalKeyringAndSyncedConnectionEntries() throws { + func testEnableConnectionWithLocalEntry() { // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) let post = makePost(disabledConnections: [ - keyringID: [.valueKey: .disabled], - connectionID: [.valueKey: .disabled, .idKey: "26"] + connectionID: [.valueKey: .disabled] ]) // When - post.enablePublicizeConnectionWithKeyringID(keyringID) + post.enablePublicizeConnection(forConnectionID: connectionID) // Then - // the local entry should be removed. - XCTAssertNil(post.disabledPublicizeConnections?[keyringID]) - - // but the synced entry should be updated. - let entry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) - XCTAssertEqual(entry[.valueKey], .enabled) + // If the entry hasn't been synced yet, it will be removed since all connections are enabled by default. + XCTAssertNil(post.disabledPublicizeConnections?[connectionID]) } - func testEnableConnectionHavingOnlyConnectionEntry() throws { + func testEnableConnectionWithSyncedEntry() throws { // Given - let keyringID = NSNumber(value: 100) let connectionID = NSNumber(value: 200) - let keyringID2 = NSNumber(value: 101) - let connectionID2 = NSNumber(value: 201) let post = makePost(disabledConnections: [ - connectionID: [.valueKey: .disabled, .idKey: "26"], - connectionID2: [.valueKey: .disabled] + connectionID: [.valueKey: .disabled, .idKey: "24"] // having an id means the entry exists on backend. ]) // When - post.enablePublicizeConnectionWithKeyringID(keyringID) - post.enablePublicizeConnectionWithKeyringID(keyringID2) + post.enablePublicizeConnection(forConnectionID: connectionID) // Then - // there shouldn't be any entries keyed by the keyringIDs. - XCTAssertNil(post.disabledPublicizeConnections?[keyringID]) - XCTAssertNil(post.disabledPublicizeConnections?[keyringID2]) - - // the entry with connectionID should be updated. let entry = try XCTUnwrap(post.disabledPublicizeConnections?[connectionID]) XCTAssertEqual(entry[.valueKey], .enabled) - - // and if the entry isn't synced, it should be deleted. - XCTAssertNil(post.disabledPublicizeConnections?[connectionID2]) } // MARK: - Helpers diff --git a/Tests/KeystoneTests/Tests/Services/PostHelperJetpackSocialTests.swift b/Tests/KeystoneTests/Tests/Services/PostHelperJetpackSocialTests.swift index 8cbfa05b676e..b154cf58264d 100644 --- a/Tests/KeystoneTests/Tests/Services/PostHelperJetpackSocialTests.swift +++ b/Tests/KeystoneTests/Tests/Services/PostHelperJetpackSocialTests.swift @@ -24,8 +24,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { let result = PostHelper.disabledPublicizeConnections(for: post, metadata: [metadataEntry]) // Then - // the keyring ID should be used as the key, while keeping the entry intact. - XCTAssertEqual(result[NSNumber(value: keyringId)], metadataEntry) + // the connection ID should be used as the key, while keeping the entry intact. + XCTAssertEqual(result[NSNumber(value: connectionId)], metadataEntry) } func testMetadataWithKeyringIDKey() { @@ -43,8 +43,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { let result = PostHelper.disabledPublicizeConnections(for: post, metadata: [metadataEntry]) // Then - // the keyring ID should be used as the key, while keeping the entry intact. - XCTAssertEqual(result[NSNumber(value: keyringId)], metadataEntry) + // the keyring ID is converted to connection ID via the PublicizeConnection lookup. + XCTAssertEqual(result[NSNumber(value: connectionId)], metadataEntry) } func testMetadataWithConnectionIDKeyNotMatchingAnyPublicizeConnections() { @@ -92,8 +92,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { let connectionId2 = 234 let keyringId2 = 567 let presetDisabledConnections: [NSNumber: [String: String]] = [ - NSNumber(value: keyringId): ["id": "10", "value": "1"], - NSNumber(value: keyringId2): ["id": "11", "key": "_wpas_skip_\(keyringId2)", "value": "0"] + NSNumber(value: connectionId): ["id": "10", "value": "1"], + NSNumber(value: connectionId2): ["id": "11", "key": "_wpas_skip_\(keyringId2)", "value": "0"] ] let connections = makeConnections(with: [(connectionId, keyringId), (connectionId2, keyringId2)]) let blog = makeBlog(connections: connections) @@ -105,8 +105,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { // Then XCTAssertEqual(entries.count, 2) - // keyless entries with id should default to the _wpas_skip_ format, despite having a matching connection. - let _ = try XCTUnwrap(entries.first(where: { $0["key"] == "_wpas_skip_\(keyringId)" })) + // keyless entries should use _wpas_skip_publicize_ format with connectionID. + let _ = try XCTUnwrap(entries.first(where: { $0["key"] == "_wpas_skip_publicize_\(connectionId)" })) // entries with keys should be passed as is. let _ = try XCTUnwrap(entries.first(where: { $0["key"] == "_wpas_skip_\(keyringId2)" })) @@ -118,8 +118,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { let connectionId2 = 234 let keyringId2 = 567 let presetDisabledConnections: [NSNumber: [String: String]] = [ - NSNumber(value: keyringId): ["value": "1"], - NSNumber(value: keyringId2): ["key": "_wpas_skip_\(keyringId2)", "value": "0"] + NSNumber(value: connectionId): ["value": "1"], + NSNumber(value: connectionId2): ["key": "_wpas_skip_\(keyringId2)", "value": "0"] ] let connections = makeConnections(with: [(connectionId, keyringId), (connectionId2, keyringId2)]) let blog = makeBlog(connections: connections) @@ -131,7 +131,7 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { // Then XCTAssertEqual(entries.count, 2) - // local entries should be updated to the _wpas_skip_publicize format. + // local entries should use _wpas_skip_publicize format with connectionID. let _ = try XCTUnwrap(entries.first(where: { $0["key"] == "_wpas_skip_publicize_\(connectionId)" })) // it's unlikely for a no-id entry to have a key, since it's assigned by the PostService when syncing. @@ -140,8 +140,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { } func testDisabledConnectionsWithoutIdAndNoMatchingPublicizeConnection() { - let nonExistentKeyringId = 3942 - let presetDisabledConnections = [NSNumber(value: nonExistentKeyringId): ["value": "1"]] + let nonExistentConnectionId = 3942 + let presetDisabledConnections = [NSNumber(value: nonExistentConnectionId): ["value": "1"]] let connections = makeConnections(with: [(connectionId, keyringId)]) let blog = makeBlog(connections: connections) let post = makePost(for: blog, disabledConnections: presetDisabledConnections) @@ -152,8 +152,8 @@ class PostHelperJetpackSocialTests: CoreDataTestCase { // Then XCTAssertEqual(entries.count, 1) - // local entries with no matching PublicizeConnection should default to _wpas_skip format. - XCTAssertEqual(entries.first?["key"], "_wpas_skip_\(nonExistentKeyringId)") + // The dictionary key is treated as connectionID, so it uses _wpas_skip_publicize_ format. + XCTAssertEqual(entries.first?["key"], "_wpas_skip_publicize_\(nonExistentConnectionId)") } func testInvalidDisabledConnectionEntry() { diff --git a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift index 116a4e82c287..60753f03fedf 100644 --- a/WordPress/Classes/Services/PostHelper+JetpackSocial.swift +++ b/WordPress/Classes/Services/PostHelper+JetpackSocial.swift @@ -7,10 +7,10 @@ extension PostHelper { /// Returns a dictionary format for the `Post`'s `disabledPublicizeConnection` property based on the given metadata. /// - /// This will try to handle both Publicize skip key formats, `_wpas_skip_{keyringID}` and `_wpas_skip_publicize_{connectionID`. - /// - /// There's a possibility that the `keyringID` obtained from remote doesn't match with any of the `PublicizeConnection` - /// that's stored locally, perhaps due to the app being out of sync. In this case, we'll fall back to using the old format. + /// This handles both Publicize skip key formats: `_wpas_skip_{keyringID}` and `_wpas_skip_publicize_{connectionID}`. + /// The dictionary key is always `connectionID`. For keyring format keys, the matching `PublicizeConnection` is + /// looked up by `keyringConnectionID` to resolve the `connectionID`. If no match is found, the raw ID is used + /// as a fallback. /// /// - Parameters: /// - post: The associated `Post` object. Optional because Obj-C shouldn't be trusted. @@ -38,26 +38,18 @@ extension PostHelper { switch prefixType { case .keyring: - return Int(key.removingPrefix(SkipPrefix.keyring.rawValue)) - - case .connection: - // If the key uses the new format, try to find an existing `PublicizeConnection` matching - // the connectionID, and return its keyringID. - let entryConnectionID = Int(key.removingPrefix(SkipPrefix.connection.rawValue)) - - guard let connections = post.blog.connections, - let connectionID = entryConnectionID, - let connection = connections.first(where: { $0.connectionID.intValue == connectionID }) else { - /// Otherwise, fall back to the connectionID extracted from the metadata key. - /// Note that entries with `connectionID` won't be detected by the Post's - /// `publicizeConnectionDisabledForKeyringID` method. - /// - /// However, the Publicize methods in `Post.swift` will attempt to update the key into - /// its `keyringID`, since the `PublicizeConnection` object is guaranteed to exist. - return entryConnectionID + let rawID = Int(key.removingPrefix(SkipPrefix.keyring.rawValue)) + // Convert keyring ID to connection ID if possible + if let rawID, + let connections = post.blog.connections, + let connection = connections.first(where: { $0.keyringConnectionID.intValue == rawID }) { + return connection.connectionID.intValue } + // Fall back to raw ID if no matching connection found + return rawID - return connection.keyringConnectionID.intValue + case .connection: + return Int(key.removingPrefix(SkipPrefix.connection.rawValue)) } } @@ -78,11 +70,11 @@ extension PostHelper { return [] } - return disabledConnectionsDictionary.compactMap { (keyringID: NSNumber, entry: StringDictionary) in + return disabledConnectionsDictionary.compactMap { (connectionID: NSNumber, entry: StringDictionary) in // The previous implementation didn't properly parse `_wpas_skip_publicize_` keys, causing it // to use 0 as the dictionary key. Although this will be ignored by the server, let's make sure // it's not sent to the remote any longer. - guard keyringID.intValue > 0 else { + guard connectionID.intValue > 0 else { return nil } @@ -92,17 +84,8 @@ extension PostHelper { return entry } - // If the key doesn't exist, this means that the dictionary is still using the old format. - // Try to add a key with the new format ONLY if the metadata hasn't been synced to the remote. - let metadataKeyValue: String = { - guard entry[Keys.publicizeIdKey] == nil, - let connections = post.blog.connections, - let connection = connections.first(where: { $0.keyringConnectionID == keyringID }) else { - // Fall back to the old keyring format. - return "\(SkipPrefix.keyring.rawValue)\(keyringID)" - } - return "\(SkipPrefix.connection.rawValue)\(connection.connectionID)" - }() + // For new entries, use the connection format since the key IS the connectionID. + let metadataKeyValue = "\(SkipPrefix.connection.rawValue)\(connectionID)" return entry.merging([Keys.publicizeKeyKey: metadataKeyValue]) { _, newValue in newValue } } diff --git a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift index cbcf04e9a1fd..61b4d9d4e7fa 100644 --- a/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift +++ b/WordPress/Classes/ViewRelated/Post/PostEditor+JetpackSocial.swift @@ -11,7 +11,7 @@ extension PostEditor { return } for connection in connections { - post.disablePublicizeConnectionWithKeyringID(connection.keyringConnectionID) + post.disablePublicizeConnection(forConnectionID: connection.connectionID) } } } diff --git a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift index 92fd79b783f4..d0218d0f7a71 100644 --- a/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift +++ b/WordPress/Classes/ViewRelated/Post/PostSettings/PostSettingsViewModel.swift @@ -630,7 +630,7 @@ extension PostSettingsViewModel: @MainActor PrepublishingSocialAccountsDelegate settings.sharing?.sharingLimit = newValue } - func didFinish(with connectionChanges: [Int: Bool], message: String?) { + func didFinish(with connectionChanges: [String: Bool], message: String?) { guard var settings = settings.sharing else { return wpAssertionFailure("social sharing settings missing") } @@ -638,7 +638,7 @@ extension PostSettingsViewModel: @MainActor PrepublishingSocialAccountsDelegate var service = $0 service.connections = service.connections.map { var connection = $0 - if let isEnabled = connectionChanges[connection.keyringID] { + if let isEnabled = connectionChanges[connection.id] { connection.enabled = isEnabled } return connection diff --git a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift index 6050c7b4bd58..bdc7b1fc1d8b 100644 --- a/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Publishing/Views/PrepublishingSocialAccountsViewController.swift @@ -8,7 +8,7 @@ protocol PrepublishingSocialAccountsDelegate: NSObjectProtocol { func didUpdateSharingLimit(with newValue: PublicizeInfo.SharingLimit?) - func didFinish(with connectionChanges: [Int: Bool], message: String?) + func didFinish(with connectionChanges: [String: Bool], message: String?) } class PrepublishingSocialAccountsViewController: UITableViewController { @@ -27,7 +27,7 @@ class PrepublishingSocialAccountsViewController: UITableViewController { private let originalMessage: String - private var connectionChanges = [Int: Bool]() + private var connectionChanges = [String: Bool]() private var sharingLimit: PublicizeInfo.SharingLimit? { didSet { @@ -77,7 +77,7 @@ class PrepublishingSocialAccountsViewController: UITableViewController { self.blogID = blogID self.connections = model.services.flatMap { service in service.connections.map { - .init(service: service.name, account: $0.account, keyringID: $0.keyringID, isOn: $0.enabled) + .init(service: service.name, account: $0.account, id: $0.id, isOn: $0.enabled) } } self.originalMessage = model.message @@ -179,7 +179,7 @@ extension PrepublishingSocialAccountsViewController { private extension PrepublishingSocialAccountsViewController { var enabledCount: Int { connections - .filter { connectionChanges[$0.keyringID] ?? $0.isOn } + .filter { connectionChanges[$0.id] ?? $0.isOn } .count } @@ -225,7 +225,7 @@ private extension PrepublishingSocialAccountsViewController { guard let connection = connections[safe: index] else { return false } - return connectionChanges[connection.keyringID] ?? connection.isOn + return connectionChanges[connection.id] ?? connection.isOn } func updateConnection(at index: Int, value: Bool) { @@ -236,9 +236,9 @@ private extension PrepublishingSocialAccountsViewController { let originalValue = connection.isOn if value == originalValue { - connectionChanges.removeValue(forKey: connection.keyringID) + connectionChanges.removeValue(forKey: connection.id) } else { - connectionChanges[connection.keyringID] = value + connectionChanges[connection.id] = value } lastToggledRow = index @@ -326,7 +326,7 @@ private extension PrepublishingSocialAccountsViewController { struct Connection { let service: PublicizeService.ServiceName let account: String - let keyringID: Int + let id: String let isOn: Bool lazy var imageForCell: UIImage = { From e9a04802b6e1985864486495c2e4c2cbb17416db Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 25 Mar 2026 23:30:35 +1300 Subject: [PATCH 2/3] Add JetpackPublicizeService for publicize sync and write operations Replace SharingSyncService with JetpackPublicizeService, which syncs connections and services via wordpress-rs wpcom/v2 endpoints and adds write operations (create, update, delete connections, fetch keyrings). --- .../Tests/Services/SharingServiceTests.swift | 84 --- .../Services/JetpackPublicizeService.swift | 566 ++++++++++++++++++ .../Classes/Services/SharingSyncService.swift | 124 ---- 3 files changed, 566 insertions(+), 208 deletions(-) delete mode 100644 Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift create mode 100644 WordPress/Classes/Services/JetpackPublicizeService.swift delete mode 100644 WordPress/Classes/Services/SharingSyncService.swift diff --git a/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift b/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift deleted file mode 100644 index 6ca7bfa6be56..000000000000 --- a/Tests/KeystoneTests/Tests/Services/SharingServiceTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -import XCTest -import WordPressKit -import OHHTTPStubs -import OHHTTPStubsSwift - -@testable import WordPress -@testable import WordPressData - -class SharingServiceTests: CoreDataTestCase { - - private let userID = 101 - private let blogID = 10 - - private lazy var account: WPAccount = { - AccountBuilder(contextManager.mainContext) - .with(id: Int64(userID)) - .with(username: "username") - .with(authToken: "authToken") - .build() - }() - - private lazy var blog: Blog = { - let blog = BlogBuilder(mainContext).with(blogID: blogID).build() - blog.account = account - - // ensure that the changes are persisted to the stack. - contextManager.saveContextAndWait(mainContext) - return blog - }() - - // MARK: Sync Publicize Connections - - func testSyncingPublicizeConnectionsForNonDotComBlogCallsACompletionBlock() throws { - let blog = Blog.createBlankBlog(in: mainContext) - blog.account = nil - - let expect = expectation(description: "Sharing service completion block called.") - - let sharingService = SharingSyncService(coreDataStack: contextManager) - sharingService.syncPublicizeConnectionsForBlog(blog) { - expect.fulfill() - } failure: { (error) in - expect.fulfill() - } - - waitForExpectations(timeout: 1, handler: nil) - } - - func testSyncingPublicizeConnectionsExcludesUnsharedConnections() async throws { - // Given - let service = SharingSyncService(coreDataStack: contextManager) - - stub(condition: isPath("/rest/v1.1/sites/\(blogID)/publicize-connections") && isMethodGET()) { _ in - HTTPStubsResponse(jsonObject: [ - "connections": [ - ["ID": 1000, "shared": "0", "user_ID": 101] as [String: Any], // owned connection - ["ID": 1001, "shared": "1", "user_ID": 201] as [String: Any], // shared connection - ["ID": 1002, "shared": "0", "user_ID": 301] as [String: Any], // private connection from others - ] - ], statusCode: 200, headers: nil) - } - - // When - try await withCheckedThrowingContinuation { continuation in - service.syncPublicizeConnectionsForBlog(blog) { - continuation.resume() - } failure: { error in - continuation.resume(throwing: error!) - } - } - - // Then - let connections = try XCTUnwrap(blog.connections) - - // the one with ID `1002` should be skipped since it's an unshared private connection from another user. - XCTAssertEqual(connections.count, 2) - - // connections owned by the user should be available. - XCTAssertTrue(connections.contains(where: { $0.connectionID.isEqual(to: NSNumber(value: 1000)) })) - - // shared connections owned by others should also be available. - XCTAssertTrue(connections.contains(where: { $0.connectionID.isEqual(to: NSNumber(value: 1001)) })) - } -} diff --git a/WordPress/Classes/Services/JetpackPublicizeService.swift b/WordPress/Classes/Services/JetpackPublicizeService.swift new file mode 100644 index 000000000000..bdc6bd06c40d --- /dev/null +++ b/WordPress/Classes/Services/JetpackPublicizeService.swift @@ -0,0 +1,566 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +import WordPressData +import WordPressKit +import WordPressShared + +/// Syncs publicize connections and services from the wordpress-rs publicize +/// endpoints, replacing the legacy `SharingSyncService` and +/// `SharingService.syncPublicizeServicesForBlog`. +@objc class JetpackPublicizeService: NSObject { + + private let coreDataStack: CoreDataStackSwift + private let wpcomClient: WordPressDotComClient + + init(coreDataStack: CoreDataStackSwift, wpcomClient: WordPressDotComClient) { + self.coreDataStack = coreDataStack + self.wpcomClient = wpcomClient + } + + /// Convenience initializer for Obj-C callers. + @objc convenience init(coreDataStack: ContextManager) { + self.init(coreDataStack: coreDataStack, wpcomClient: WordPressDotComClient()) + } + + /// Fetches publicize connections from the remote and merges them into + /// Core Data, deleting any that no longer exist on the server. + func syncConnections(for blogID: TaggedManagedObjectID) async throws { + let siteID: UInt64 = try await coreDataStack.performQuery { context in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + return dotComID + } + + let response = try await wpcomClient.api.publicize.listConnections( + wpComSiteId: siteID + ) + + let currentUserID: Int64 = await coreDataStack.performQuery { context in + guard let blog = try? context.existingObject(with: blogID), + let accountID = blog.account?.userID else { + return 0 + } + return accountID.int64Value + } + + // Only keep connections that are shared, global, or owned by the current user. + let authorizedConnections = response.data.filter { + $0.shared || $0.global || $0.wpcomUserId == currentUserID + } + + try await mergeConnections(authorizedConnections, blogID: blogID) + } + + /// Fetches available publicize services from the remote and merges them + /// into Core Data, deleting any that no longer exist on the server. + func syncServices(for blogID: TaggedManagedObjectID) async throws { + let siteID: UInt64 = try await coreDataStack.performQuery { context in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + return dotComID + } + + let response = try await wpcomClient.api.publicize.listServices(wpComSiteId: siteID) + try await mergeServices(response.data) + } + + /// Fetches the current user's keyring connections. Returns in-memory + /// objects — nothing is saved to Core Data. + func fetchKeyringConnections() async throws -> [KeyringConnection] { + let response = try await wpcomClient.api.meConnections.list() + return response.data.connections.map { Self.mapKeyringConnection(from: $0) } + } + + /// Creates a new publicize connection using a keyring connection. + func createConnection( + for blogID: TaggedManagedObjectID, + keyringConnectionId: Int64, + externalUserId: String? + ) async throws -> TaggedManagedObjectID { + let siteID: UInt64 = try await coreDataStack.performQuery { context in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + return dotComID + } + + let params = CreatePublicizeConnectionParams( + keyringConnectionId: keyringConnectionId, + externalUserId: externalUserId, + shared: nil + ) + let response = try await wpcomClient.api.publicize.createConnection( + wpComSiteId: siteID, + params: params + ) + + let serviceName = response.data.serviceName + let objectID: TaggedManagedObjectID = try await coreDataStack.performAndSave { context in + let blog = try context.existingObject(with: blogID) + let connection = Self.findOrCreateConnection( + connectionID: response.data.connectionId, + in: context + ) + Self.update(connection, from: response.data) + connection.blog = blog + try context.obtainPermanentIDs(for: [connection]) + return TaggedManagedObjectID(connection) + } + + let properties = ["service": serviceName] + WPAppAnalytics.track(.sharingPublicizeConnected, properties: properties, blogID: NSNumber(value: siteID)) + + return objectID + } + + /// Updates the shared status of a publicize connection. + /// Uses optimistic update — reverts on failure. + func updateConnectionShared( + for blogID: TaggedManagedObjectID, + connectionId: String, + shared: Bool + ) async throws { + let (siteID, oldValue, service) = try await coreDataStack.performAndSave { context -> (UInt64, Bool, String) in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + let connection = Self.findOrCreateConnection(connectionID: connectionId, in: context) + let oldValue = connection.shared + connection.shared = shared + return (dotComID, oldValue, connection.service) + } + + guard oldValue != shared else { return } + + do { + let params = UpdatePublicizeConnectionParams( + externalUserId: nil, + shared: shared + ) + let response = try await wpcomClient.api.publicize.updateConnection( + wpComSiteId: siteID, + publicizeConnectionId: PublicizeConnectionId(connectionId), + params: params + ) + + try await coreDataStack.performAndSave { context in + let blog = try context.existingObject(with: blogID) + let connection = Self.findOrCreateConnection( + connectionID: response.data.connectionId, + in: context + ) + Self.update(connection, from: response.data) + connection.blog = blog + } + + let properties = [ + "service": service, + "is_site_wide": NSNumber(value: shared).stringValue + ] + WPAppAnalytics.track(.sharingPublicizeConnectionAvailableToAllChanged, properties: properties, blogID: NSNumber(value: siteID)) + } catch { + // Revert optimistic update + try? await coreDataStack.performAndSave { context in + let connection = Self.findOrCreateConnection(connectionID: connectionId, in: context) + connection.shared = oldValue + } + throw error + } + } + + /// Updates the external user ID of a publicize connection. + func updateConnectionExternalId( + for blogID: TaggedManagedObjectID, + connectionId: String, + externalId: String + ) async throws { + let siteID: UInt64 = try await coreDataStack.performQuery { context in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + return dotComID + } + + let params = UpdatePublicizeConnectionParams( + externalUserId: externalId, + shared: nil + ) + let response = try await wpcomClient.api.publicize.updateConnection( + wpComSiteId: siteID, + publicizeConnectionId: PublicizeConnectionId(connectionId), + params: params + ) + + try await coreDataStack.performAndSave { context in + let blog = try context.existingObject(with: blogID) + let connection = Self.findOrCreateConnection( + connectionID: response.data.connectionId, + in: context + ) + Self.update(connection, from: response.data) + connection.blog = blog + } + } + + /// Deletes a publicize connection. Uses optimistic delete — removes from + /// Core Data immediately, then calls the API. + func deleteConnection( + for blogID: TaggedManagedObjectID, + connectionId: String + ) async throws { + let (siteID, service) = try await coreDataStack.performAndSave { context -> (UInt64, String) in + let blog = try context.existingObject(with: blogID) + guard let dotComID = blog.dotComID?.uint64Value else { + throw JetpackPublicizeServiceError.missingSiteID + } + let connection = Self.findOrCreateConnection(connectionID: connectionId, in: context) + let service = connection.service + context.delete(connection) + return (dotComID, service) + } + + do { + _ = try await wpcomClient.api.publicize.deleteConnection( + wpComSiteId: siteID, + publicizeConnectionId: PublicizeConnectionId(connectionId) + ) + } catch { + // FIXME: The error handling here may need adjustment. The wordpress-rs API may not + // surface not_found errors in the same way as the legacy WordPressComRestApi. + let nsError = error as NSError + if let errorCode = nsError.userInfo["WordPressComRestApiErrorCodeKey"] as? String, + errorCode == "not_found" { + // Already disconnected — treat as success + } else { + throw error + } + } + + let properties = ["service": service] + WPAppAnalytics.track(.sharingPublicizeDisconnected, properties: properties, blogID: NSNumber(value: siteID)) + } + + // MARK: - Obj-C Bridge Methods + + @objc func syncConnections( + for blog: Blog, + success: (() -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + Task { @MainActor in + do { + try await syncConnections(for: blogID) + success?() + } catch { + failure?(error as NSError) + } + } + } + + @objc func syncServices( + for blog: Blog, + success: (() -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + Task { @MainActor in + do { + try await syncServices(for: blogID) + success?() + } catch { + failure?(error as NSError) + } + } + } + + @objc func fetchKeyringConnections( + for blog: Blog, + success: (([KeyringConnection]) -> Void)?, + failure: ((NSError?) -> Void)? + ) { + Task { @MainActor in + do { + let connections = try await fetchKeyringConnections() + success?(connections) + } catch { + failure?(error as NSError) + } + } + } + + @objc func createConnection( + for blog: Blog, + keyring: KeyringConnection, + externalUserID: String?, + success: ((PublicizeConnection) -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + Task { @MainActor in + do { + let objectID = try await createConnection( + for: blogID, + keyringConnectionId: keyring.keyringID.int64Value, + externalUserId: externalUserID + ) + let connection = try self.coreDataStack.mainContext.existingObject(with: objectID) + success?(connection) + } catch { + failure?(error as NSError) + } + } + } + + @objc func updateShared( + for blog: Blog, + shared: Bool, + forPublicizeConnection pubConn: PublicizeConnection, + success: (() -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + let connectionId = String(pubConn.connectionID.intValue) + Task { @MainActor in + do { + try await updateConnectionShared( + for: blogID, + connectionId: connectionId, + shared: shared + ) + success?() + } catch { + failure?(error as NSError) + } + } + } + + @objc func updateExternalID( + _ externalID: String, + for blog: Blog, + forPublicizeConnection pubConn: PublicizeConnection, + success: (() -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + let connectionId = String(pubConn.connectionID.intValue) + Task { @MainActor in + do { + try await updateConnectionExternalId( + for: blogID, + connectionId: connectionId, + externalId: externalID + ) + success?() + } catch { + failure?(error as NSError) + } + } + } + + @objc func deleteConnection( + for blog: Blog, + pubConn: PublicizeConnection, + success: (() -> Void)?, + failure: ((NSError?) -> Void)? + ) { + let blogID = TaggedManagedObjectID(blog) + let connectionId = String(pubConn.connectionID.intValue) + Task { @MainActor in + do { + try await deleteConnection(for: blogID, connectionId: connectionId) + success?() + } catch { + failure?(error as NSError) + } + } + } + + // MARK: - Private Merge Methods + + /// Merges remote connections into Core Data for a given blog. + private func mergeConnections( + _ remoteConnections: [PublicizeConnectionResponse], + blogID: TaggedManagedObjectID + ) async throws { + try await coreDataStack.performAndSave { context in + let blog: Blog + do { + blog = try context.existingObject(with: blogID) + } catch { + Loggers.app.error("Error fetching Blog: \(error)") + return + } + + let currentConnections = Self.fetchExistingConnections(for: blog, in: context) + + let connectionsToKeep = remoteConnections.map { remote -> PublicizeConnection in + let connection = Self.findOrCreateConnection( + connectionID: remote.connectionId, + in: context + ) + Self.update(connection, from: remote) + connection.blog = blog + return connection + } + + for connection in currentConnections { + if !connectionsToKeep.contains(connection) { + context.delete(connection) + } + } + } + } + + /// Merges remote services into Core Data. This is global (not blog-scoped), + /// matching the behavior of the legacy `SharingService`. + private func mergeServices(_ remoteServices: [PublicizeServiceResponse]) async throws { + try await coreDataStack.performAndSave { context in + let currentServices = (try? PublicizeService.allPublicizeServices(in: context)) ?? [] + + let servicesToKeep = remoteServices.map { remote -> PublicizeService in + Self.createOrUpdateService(from: remote, in: context) + } + + for service in currentServices { + if !servicesToKeep.contains(service) { + context.delete(service) + } + } + } + } + + // MARK: - Connection Helpers + + private static func fetchExistingConnections( + for blog: Blog, + in context: NSManagedObjectContext + ) -> [PublicizeConnection] { + let request = NSFetchRequest( + entityName: PublicizeConnection.classNameWithoutNamespaces() + ) + request.predicate = NSPredicate(format: "blog = %@", blog) + + do { + return try context.fetch(request) as! [PublicizeConnection] + } catch { + Loggers.app.error("Error fetching Publicize Connections: \(error.localizedDescription)") + return [] + } + } + + private static func findOrCreateConnection( + connectionID: String, + in context: NSManagedObjectContext + ) -> PublicizeConnection { + if let connectionIDNumber = Int(connectionID) { + let request = NSFetchRequest( + entityName: PublicizeConnection.classNameWithoutNamespaces() + ) + request.predicate = NSPredicate( + format: "connectionID = %@", + NSNumber(value: connectionIDNumber) + ) + if let existing = try? context.fetch(request).first { + return existing + } + } + + return NSEntityDescription.insertNewObject( + forEntityName: PublicizeConnection.classNameWithoutNamespaces(), + into: context + ) as! PublicizeConnection + } + + private static func update( + _ connection: PublicizeConnection, + from remote: PublicizeConnectionResponse + ) { + if let idValue = Int(remote.connectionId) { + connection.connectionID = NSNumber(value: idValue) + } + connection.externalDisplay = remote.displayName + connection.service = remote.serviceName + connection.externalProfilePicture = remote.profilePicture + connection.externalProfileURL = remote.profileLink + connection.status = remote.status ?? "ok" + connection.shared = remote.shared || remote.global + connection.externalID = remote.externalId + connection.externalName = remote.username + + // TODO: Unmapped remote fields: id, externalHandle, serviceLabel, + // profileDisplayName, wpcomUserId + // TODO: Unmapped Core Data fields: dateIssued, dateExpires, + // externalFollowerCount, keyringConnectionID, keyringConnectionUserID, + // label, refreshURL, siteID, userID + } + + // MARK: - Service Helpers + + private static func createOrUpdateService( + from remote: PublicizeServiceResponse, + in context: NSManagedObjectContext + ) -> PublicizeService { + var service = try? PublicizeService.lookupPublicizeServiceNamed(remote.id, in: context) + if service == nil { + service = NSEntityDescription.insertNewObject( + forEntityName: PublicizeService.entityName(), + into: context + ) as? PublicizeService + } + + service?.serviceID = remote.id + service?.detail = remote.description + service?.label = remote.label + service?.status = remote.status.isEmpty ? PublicizeService.defaultStatus : remote.status + service?.multipleExternalUserIDSupport = remote.supports.additionalUsers + service?.externalUsersOnly = remote.supports.additionalUsersOnly + service?.connectURL = remote.url + + // TODO: Unmapped Core Data fields: icon (not in v2 response), + // jetpackSupport, jetpackModuleRequired, order, type + + return service! + } + + // MARK: - Keyring Mapping + + private static func mapKeyringConnection( + from response: KeyringConnectionResponse + ) -> KeyringConnection { + let conn = KeyringConnection() + conn.keyringID = NSNumber(value: response.id) + conn.userID = NSNumber(value: response.userId) + conn.service = response.service + conn.label = response.label ?? "" + conn.externalID = response.externalId + conn.externalName = response.externalName + conn.externalDisplay = response.externalDisplay + conn.externalProfilePicture = response.externalProfilePicture ?? "" + conn.status = response.status + conn.refreshURL = response.refreshUrl + conn.additionalExternalUsers = response.additionalExternalUsers.map { user in + let extUser = KeyringConnectionExternalUser() + extUser.externalID = user.externalId + extUser.externalName = user.externalName + extUser.externalProfilePicture = user.externalProfilePicture ?? "" + extUser.externalCategory = user.externalCategory ?? "" + return extUser + } + return conn + } + + // MARK: - Error + + enum JetpackPublicizeServiceError: Error { + case missingSiteID + } +} diff --git a/WordPress/Classes/Services/SharingSyncService.swift b/WordPress/Classes/Services/SharingSyncService.swift deleted file mode 100644 index bf6baab6391c..000000000000 --- a/WordPress/Classes/Services/SharingSyncService.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation -import WordPressData -import WordPressShared -import WordPressKit - -/// SharingService is responsible for wrangling publicize services, publicize -/// connections, and keyring connections. -/// -@objc public class SharingSyncService: NSObject { - - let coreDataStack: CoreDataStack - - @objc public init(coreDataStack: CoreDataStack) { - self.coreDataStack = coreDataStack - } - - /// Returns the remote to use with the service. - /// - /// - Parameter blog: The blog to use for the rest api. - /// - private func remoteForBlog(_ blog: Blog) -> SharingServiceRemote? { - guard let api = blog.wordPressComRestApi else { - return nil - } - - return SharingServiceRemote(wordPressComRestApi: api) - } - - /// Syncs Publicize connections for the specified wpcom blog. - /// - /// - Parameters: - /// - blog: The `Blog` for which to sync publicize connections - /// - success: An optional success block accepting no parameters. - /// - failure: An optional failure block accepting an `NSError` parameter. - /// - @objc open func syncPublicizeConnectionsForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { - guard let remote = remoteForBlog(blog), - let blogID = blog.dotComID else { - failure?(Error.siteWithNoRemote as NSError) - return - } - - remote.getPublicizeConnections(blogID, success: { [blogObjectID = blog.objectID] remoteConnections in - let currentUserID: NSNumber = self.coreDataStack.performQuery { context in - guard let blog = try? context.existingObject(with: blogObjectID) as? Blog, - let accountID = blog.account?.userID else { - return NSNumber(value: 0) - } - return accountID - } - - // Ensure that we're only processing shared or owned connections - let authorizedConnections = remoteConnections.filter { $0.shared || $0.userID.isEqual(to: currentUserID) } - - // Process the results - self.mergePublicizeConnectionsForBlog(blogObjectID, remoteConnections: authorizedConnections, onComplete: success) - }, failure: failure) - } - - /// Called when syncing Publicize connections. Merges synced and cached data, removing - /// anything that does not exist on the server. Saves the context. - /// - /// - Parameters: - /// - blogObjectID: the NSManagedObjectID of a `Blog` - /// - remoteConnections: An array of `RemotePublicizeConnection` objects to merge. - /// - onComplete: An optional callback block to be performed when core data has saved the changes. - /// - private func mergePublicizeConnectionsForBlog(_ blogObjectID: NSManagedObjectID, remoteConnections: [RemotePublicizeConnection], onComplete: (() -> Void)?) { - coreDataStack.performAndSave({ context in - var blog: Blog - do { - blog = try context.existingObject(with: blogObjectID) as! Blog - } catch let error as NSError { - DDLogError("Error fetching Blog: \(error)") - // Because of the error we'll bail early, but we still need to call - // the success callback if one was passed. - return - } - - let currentPublicizeConnections = self.allPublicizeConnections(for: blog, in: context) - - // Create or update based on the contents synced. - let connectionsToKeep = remoteConnections.map { (remoteConnection) -> PublicizeConnection in - let pubConnection = PublicizeConnection.createOrReplace(from: remoteConnection, in: context) - pubConnection.blog = blog - return pubConnection - } - - // Delete any cached PublicizeServices that were not synced. - for pubConnection in currentPublicizeConnections { - if !connectionsToKeep.contains(pubConnection) { - context.delete(pubConnection) - } - } - }, completion: { onComplete?() }, on: .main) - } - - /// Returns an array of all cached `PublicizeConnection` objects. - /// - /// - Parameters - /// - blog: A `Blog` object - /// - /// - Returns: An array of `PublicizeConnection`. The array is empty if no objects are cached. - /// - private func allPublicizeConnections(for blog: Blog, in context: NSManagedObjectContext) -> [PublicizeConnection] { - let request = NSFetchRequest(entityName: PublicizeConnection.classNameWithoutNamespaces()) - request.predicate = NSPredicate(format: "blog = %@", blog) - - var connections: [PublicizeConnection] - do { - connections = try context.fetch(request) as! [PublicizeConnection] - } catch let error as NSError { - DDLogError("Error fetching Publicize Connections: \(error.localizedDescription)") - connections = [] - } - - return connections - } - - // Error for failure states - enum Error: Swift.Error { - case siteWithNoRemote - } -} From a9c6be6c1c9cc4f922006f0f10dbc723e23bf6cc Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 25 Mar 2026 23:31:22 +1300 Subject: [PATCH 3/3] Migrate publicize operations to JetpackPublicizeService Move all publicize connection operations (create, update, delete, fetch keyrings) from SharingService/SharingServiceRemote to JetpackPublicizeService. Remove the legacy v1.1 API methods and update all Obj-C callers. --- .../WordPressKit/SharingServiceRemote.swift | 444 ------------------ WordPress/Classes/Services/BlogService.m | 21 +- .../Classes/Services/SharingService.swift | 344 -------------- .../Blog/Sharing/SharingAuthorizationHelper.m | 22 +- .../Sharing/SharingDetailViewController.m | 35 +- .../Blog/Sharing/SharingViewController.m | 8 +- 6 files changed, 38 insertions(+), 836 deletions(-) diff --git a/Modules/Sources/WordPressKit/SharingServiceRemote.swift b/Modules/Sources/WordPressKit/SharingServiceRemote.swift index a6bd0e6ebf1b..7e0789eabb72 100644 --- a/Modules/Sources/WordPressKit/SharingServiceRemote.swift +++ b/Modules/Sources/WordPressKit/SharingServiceRemote.swift @@ -30,367 +30,6 @@ open class SharingServiceRemote: ServiceRemoteWordPressComREST { 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 = WPKitDateUtils.date(fromISOString: dict.string(forKey: ConnectionDictionaryKeys.expires)) - conn.dateIssued = WPKitDateUtils.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 = WPKitDateUtils.date(fromISOString: expirationDateAsString) - } - - if let issueDateAsString = dict.string(forKey: ConnectionDictionaryKeys.issued) { - conn.dateIssued = WPKitDateUtils.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. @@ -508,89 +147,6 @@ open class SharingServiceRemote: ServiceRemoteWordPressComREST { }) } - 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 diff --git a/WordPress/Classes/Services/BlogService.m b/WordPress/Classes/Services/BlogService.m index b86012ec1a3c..fb7731675b24 100644 --- a/WordPress/Classes/Services/BlogService.m +++ b/WordPress/Classes/Services/BlogService.m @@ -152,20 +152,17 @@ - (void)syncBlogAndAllMetadata:(Blog *)blog completionHandler:(void (^)(void))co dispatch_group_leave(syncGroup); }]; - SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:self.coreDataStack]; + JetpackPublicizeService *publicizeService = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; dispatch_group_enter(syncGroup); - [sharingService syncPublicizeConnectionsForBlog:blog - success:^{ - dispatch_group_leave(syncGroup); - } - failure:^(NSError *error) { - DDLogError(@"Failed syncing publicize connections for blog %@: %@", blog.url, error); - dispatch_group_leave(syncGroup); - }]; - - SharingService *publicizeService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; + [publicizeService syncConnectionsFor:blog success:^{ + dispatch_group_leave(syncGroup); + } failure:^(NSError *error) { + DDLogError(@"Failed syncing publicize connections for blog %@: %@", blog.url, error); + dispatch_group_leave(syncGroup); + }]; + dispatch_group_enter(syncGroup); - [publicizeService syncPublicizeServicesForBlog:blog success:^{ + [publicizeService syncServicesFor:blog success:^{ dispatch_group_leave(syncGroup); } failure:^(NSError * _Nullable error) { DDLogError(@"Failed syncing publicize services for blog %@: %@", blog.url, error); diff --git a/WordPress/Classes/Services/SharingService.swift b/WordPress/Classes/Services/SharingService.swift index 928c619fa4ae..19e20bf23ea3 100644 --- a/WordPress/Classes/Services/SharingService.swift +++ b/WordPress/Classes/Services/SharingService.swift @@ -7,8 +7,6 @@ import WordPressKit /// connections, and keyring connections. /// @objc public class SharingService: NSObject { - let SharingAPIErrorNotFound = "not_found" - private let coreDataStack: CoreDataStackSwift /// The initialiser for Objective-C code. @@ -23,348 +21,6 @@ import WordPressKit self.coreDataStack = coreDataStack } - // MARK: - Publicize Related Methods - - /// Syncs the list of Publicize services. The list is expected to very rarely change. - /// - /// - Parameters: - /// - blog: The `Blog` for which to sync publicize services - /// - success: An optional success block accepting no parameters - /// - failure: An optional failure block accepting an `NSError` parameter - /// - @objc public func syncPublicizeServicesForBlog(_ blog: Blog, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { - guard let remote = remoteForBlog(blog), - let blogID = blog.dotComID else { - failure?(nil) - return - } - - remote.getPublicizeServices(for: blogID, success: { remoteServices in - // Process the results - self.mergePublicizeServices(remoteServices, success: success) - }, failure: failure) - } - - /// Fetches the current user's list of keyring connections. Nothing is saved to core data. - /// The success block should accept an array of `KeyringConnection` objects. - /// - /// - Parameters: - /// - blog: The `Blog` for which to sync keyring connections - /// - success: An optional success block accepting an array of `KeyringConnection` objects - /// - failure: An optional failure block accepting an `NSError` parameter - /// - @objc public func fetchKeyringConnectionsForBlog(_ blog: Blog, success: (([KeyringConnection]) -> Void)?, failure: ((NSError?) -> Void)?) { - guard let remote = remoteForBlog(blog) else { - return - } - remote.getKeyringConnections({ keyringConnections in - // Just return the result - success?(keyringConnections) - }, - failure: failure) - } - - /// Creates a new publicize connection for the specified `Blog`, using the specified - /// keyring. Optionally the connection can target a particular external user account. - /// - /// - Parameters - /// - blog: The `Blog` for which to sync publicize connections - /// - keyring: The `KeyringConnection` to use - /// - externalUserID: An optional string representing a user ID on the external service. - /// - success: An optional success block accepting a `PublicizeConnection` parameter. - /// - failure: An optional failure block accepting an NSError parameter. - /// - @objc public func createPublicizeConnectionForBlog(_ blog: Blog, - keyring: KeyringConnection, - externalUserID: String?, - success: ((PublicizeConnection) -> Void)?, - failure: ((NSError?) -> Void)?) { - let blogObjectID = blog.objectID - guard let remote = remoteForBlog(blog) else { - return - } - let dotComID = blog.dotComID! - remote.createPublicizeConnection( - dotComID, - keyringConnectionID: keyring.keyringID, - externalUserID: externalUserID, - success: { remoteConnection in - let properties = [ - "service": keyring.service - ] - WPAppAnalytics.track(.sharingPublicizeConnected, properties: properties, blogID: dotComID) - - self.coreDataStack.performAndSave({ context -> NSManagedObjectID in - try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) - }, completion: { result in - let transformed = result.flatMap { objectID in - Result { - let object = try self.coreDataStack.mainContext.existingObject(with: objectID) - return object as! PublicizeConnection - } - } - switch transformed { - case let .success(object): - success?(object) - case let .failure(error): - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error as NSError) - } - }, on: .main) - }, - failure: { (error: NSError?) in - failure?(error) - }) - } - - /// Update the specified `PublicizeConnection`. The update to core data is performed - /// optimistically. In case of failure the original value will be restored. - /// - /// - Parameters: - /// - shared: True if the connection should be shared with all users of the blog. - /// - pubConn: The `PublicizeConnection` to update - /// - success: An optional success block accepting no parameters. - /// - failure: An optional failure block accepting an NSError parameter. - /// - @objc public func updateSharedForBlog( - _ blog: Blog, - shared: Bool, - forPublicizeConnection pubConn: PublicizeConnection, - success: (() -> Void)?, - failure: ((NSError?) -> Void)? - ) { - typealias PubConnUpdateResult = (oldValue: Bool, siteID: NSNumber, connectionID: NSNumber, service: String, remote: SharingServiceRemote?) - - let blogObjectID = blog.objectID - coreDataStack.performAndSave({ context -> PubConnUpdateResult in - let blogInContext = try context.existingObject(with: blogObjectID) as! Blog - let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection - let oldValue = pubConnInContext.shared - pubConnInContext.shared = shared - return ( - oldValue: oldValue, - siteID: pubConnInContext.siteID, - connectionID: pubConnInContext.connectionID, - service: pubConnInContext.service, - remote: self.remoteForBlog(blogInContext) - ) - }, completion: { result in - switch result { - case let .success(value): - if value.oldValue == shared { - success?() - return - } - - value.remote?.updatePublicizeConnectionWithID( - value.connectionID, - shared: shared, - forSite: value.siteID, - success: { remoteConnection in - let properties = [ - "service": value.service, - "is_site_wide": NSNumber(value: shared).stringValue - ] - WPAppAnalytics.track(.sharingPublicizeConnectionAvailableToAllChanged, properties: properties, blogID: value.siteID) - - self.coreDataStack.performAndSave({ context in - try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) - }, completion: { result in - switch result { - case .success: - success?() - case let .failure(error): - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error as NSError) - } - }, on: .main) - }, - failure: { (error: NSError?) in - self.coreDataStack.performAndSave({ context in - let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection - pubConnInContext.shared = value.oldValue - }, completion: { _ in - failure?(error) - }, on: .main) - } - ) - case let .failure(error): - failure?(error as NSError) - } - }, on: .main) - } - - /// Update the specified `PublicizeConnection`. The update to core data is performed - /// optimistically. In case of failure the original value will be restored. - /// - /// - Parameters: - /// - externalID: True if the connection should be shared with all users of the blog. - /// - pubConn: The `PublicizeConnection` to update - /// - success: An optional success block accepting no parameters. - /// - failure: An optional failure block accepting an NSError parameter. - /// - @objc public func updateExternalID(_ externalID: String, - forBlog blog: Blog, - forPublicizeConnection pubConn: PublicizeConnection, - success: (() -> Void)?, - failure: ((NSError?) -> Void)?) { - if pubConn.externalID == externalID { - success?() - return - } - - let blogObjectID = blog.objectID - let siteID = pubConn.siteID - guard let remote = remoteForBlog(blog) else { - return - } - remote.updatePublicizeConnectionWithID(pubConn.connectionID, - externalID: externalID, - forSite: siteID, - success: { remoteConnection in - self.coreDataStack.performAndSave({ context in - try self.createOrReplacePublicizeConnectionForBlogWithObjectID(blogObjectID, remoteConnection: remoteConnection, in: context) - }, completion: { result in - switch result { - case .success: - success?() - case let .failure(error): - DDLogError("Error creating publicize connection from remote: \(error)") - failure?(error as NSError) - } - }, on: .main) - }, - failure: failure) - } - - /// Deletes the specified `PublicizeConnection`. The delete from core data is performed - /// optimistically. The caller's `failure` block should be responsible for resyncing - /// the deleted connection. - /// - /// - Parameters: - /// - pubConn: The `PublicizeConnection` to delete - /// - success: An optional success block accepting no parameters. - /// - failure: An optional failure block accepting an NSError parameter. - /// - @objc public func deletePublicizeConnectionForBlog(_ blog: Blog, pubConn: PublicizeConnection, success: (() -> Void)?, failure: ((NSError?) -> Void)?) { - // optimistically delete the connection locally. - coreDataStack.performAndSave({ context in - let blogInContext = try context.existingObject(with: blog.objectID) as! Blog - let pubConnInContext = try context.existingObject(with: pubConn.objectID) as! PublicizeConnection - - let siteID = pubConnInContext.siteID - context.delete(pubConnInContext) - return (siteID, pubConnInContext.connectionID, pubConnInContext.service, self.remoteForBlog(blogInContext)) - }, completion: { result in - switch result { - case let .success((siteID, connectionID, service, remote)): - remote?.deletePublicizeConnection( - siteID, - connectionID: connectionID, - success: { - let properties = [ - "service": service - ] - WPAppAnalytics.track(.sharingPublicizeDisconnected, properties: properties, blogID: siteID) - success?() - }, - failure: { (error: NSError?) in - if let errorCode = error?.userInfo[WordPressComRestApi.ErrorKeyErrorCode] as? String { - if errorCode == self.SharingAPIErrorNotFound { - // This is a special situation. If the call to disconnect the service returns not_found then the service - // has probably already been disconnected and the call was made with stale data. - // Assume this is the case and treat this error as a successful disconnect. - success?() - return - } - } - failure?(error) - } - ) - case let .failure(error): - failure?(error as NSError) - } - }, on: .main) - } - - // MARK: - Private PublicizeService Methods - - /// Called when syncing Publicize services. Merges synced and cached data, removing - /// anything that does not exist on the server. Saves the context. - /// - /// - Parameters - /// - remoteServices: An array of `RemotePublicizeService` objects to merge. - /// - success: An optional callback block to be performed when core data has saved the changes. - /// - private func mergePublicizeServices(_ remoteServices: [RemotePublicizeService], success: (() -> Void)? ) { - coreDataStack.performAndSave({ context in - let currentPublicizeServices = (try? PublicizeService.allPublicizeServices(in: context)) ?? [] - - // Create or update based on the contents synced. - let servicesToKeep = remoteServices.map { (remoteService) -> PublicizeService in - self.createOrReplaceFromRemotePublicizeService(remoteService, in: context) - } - - // Delete any cached PublicizeServices that were not synced. - for pubService in currentPublicizeServices { - if !servicesToKeep.contains(pubService) { - context.delete(pubService) - } - } - }, completion: success, on: .main) - } - - /// Composes a new `PublicizeService`, or updates an existing one, with data represented by the passed `RemotePublicizeService`. - /// - /// - Parameter remoteService: The remote publicize service representing a `PublicizeService` - /// - /// - Returns: A `PublicizeService`. - /// - private func createOrReplaceFromRemotePublicizeService(_ remoteService: RemotePublicizeService, in context: NSManagedObjectContext) -> PublicizeService { - var pubService = try? PublicizeService.lookupPublicizeServiceNamed(remoteService.serviceID, in: context) - if pubService == nil { - pubService = NSEntityDescription.insertNewObject(forEntityName: PublicizeService.entityName(), - into: context) as? PublicizeService - } - pubService?.connectURL = remoteService.connectURL - pubService?.detail = remoteService.detail - pubService?.externalUsersOnly = remoteService.externalUsersOnly - pubService?.icon = remoteService.icon - pubService?.jetpackModuleRequired = remoteService.jetpackModuleRequired - pubService?.jetpackSupport = remoteService.jetpackSupport - pubService?.label = remoteService.label - pubService?.multipleExternalUserIDSupport = remoteService.multipleExternalUserIDSupport - pubService?.order = remoteService.order - pubService?.serviceID = remoteService.serviceID - pubService?.type = remoteService.type - pubService?.status = (remoteService.status.isEmpty ? PublicizeService.defaultStatus : remoteService.status) - - return pubService! - } - - // MARK: - Private PublicizeConnection Methods - - /// Composes a new `PublicizeConnection`, with data represented by the passed `RemotePublicizeConnection`. - /// Throws an error if unable to find a `Blog` for the `blogObjectID` - /// - /// - Parameter blogObjectID: And `NSManagedObjectID` for for a `Blog` entity. - /// - /// - Returns: A `PublicizeConnection`. - /// - private func createOrReplacePublicizeConnectionForBlogWithObjectID( - _ blogObjectID: NSManagedObjectID, - remoteConnection: RemotePublicizeConnection, - in context: NSManagedObjectContext - ) throws -> NSManagedObjectID { - let blog = try context.existingObject(with: blogObjectID) as! Blog - let pubConn = PublicizeConnection.createOrReplace(from: remoteConnection, in: context) - pubConn.blog = blog - - try context.obtainPermanentIDs(for: [pubConn]) - - return pubConn.objectID - } - // MARK: Sharing Button Related Methods /// Syncs `SharingButton`s for the specified wpcom blog. diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m index 5ab711535e79..1a4a1e3e4bd8 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingAuthorizationHelper.m @@ -107,8 +107,8 @@ - (void)authorizeDidSucceed:(PublicizeService *)publicizer if (self.reconnecting) { // Resync publicize connections. - SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; - [sharingService syncPublicizeConnectionsForBlog:self.blog success:^{ + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service syncConnectionsFor:self.blog success:^{ [self handleReconnectSucceeded]; } failure:^(NSError *error) { DDLogError([error description]); @@ -173,9 +173,9 @@ - (void)fetchKeyringConnectionsForService:(PublicizeService *)pubServ [self.delegate sharingAuthorizationHelper:self willFetchKeyringsForService:self.publicizeService]; } - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; - [sharingService fetchKeyringConnectionsForBlog:self.blog success:^(NSArray *keyringConnections) { + [service fetchKeyringConnectionsFor:self.blog success:^(NSArray *keyringConnections) { if ([weakSelf.delegate respondsToSelector:@selector(sharingAuthorizationHelper:didFetchKeyringsForService:)]) { [weakSelf.delegate sharingAuthorizationHelper:weakSelf didFetchKeyringsForService:weakSelf.publicizeService]; } @@ -338,9 +338,9 @@ - (void)updateConnection:(PublicizeConnection *)publicizeConnection forKeyringCo [self dismissNavViewController]; - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; - [sharingService updateExternalID:externalID forBlog:self.blog forPublicizeConnection:publicizeConnection success:^{ + [service updateExternalID:externalID for:self.blog forPublicizeConnection:publicizeConnection success:^{ if ([self.delegate respondsToSelector:@selector(sharingAuthorizationHelper:didConnectToService:withPublicizeConnection:)]) { [self.delegate sharingAuthorizationHelper:self didConnectToService:self.publicizeService withPublicizeConnection:publicizeConnection]; } @@ -365,11 +365,11 @@ - (void)connectToServiceWithKeyringConnection:(KeyringConnection *)keyConn andEx [self dismissNavViewController]; - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; - [sharingService createPublicizeConnectionForBlog:self.blog - keyring:keyConn - externalUserID:externalUserID - success:^(PublicizeConnection *pubConn) { + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service createConnectionFor:self.blog + keyring:keyConn + externalUserID:externalUserID + success:^(PublicizeConnection *pubConn) { if ([self.delegate respondsToSelector:@selector(sharingAuthorizationHelper:didConnectToService:withPublicizeConnection:)]) { [self.delegate sharingAuthorizationHelper:self didConnectToService:self.publicizeService withPublicizeConnection:pubConn]; } diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingDetailViewController.m b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingDetailViewController.m index 9a1027836e59..3dbbdb08892e 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingDetailViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingDetailViewController.m @@ -222,31 +222,24 @@ - (SwitchTableViewCell *)switchTableViewCell - (void)updateSharedGlobally:(BOOL)shared { __weak __typeof(self) weakSelf = self; - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; - [sharingService updateSharedForBlog:self.blog - shared:shared - forPublicizeConnection:self.publicizeConnection - success:nil - failure:^(NSError *error) { - DDLogError([error description]); - [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Change failed", @"Message to show when Publicize globally shared setting failed")]; - [weakSelf.tableView reloadData]; - }]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service updateSharedFor:self.blog shared:shared forPublicizeConnection:self.publicizeConnection success:nil failure:^(NSError *error) { + DDLogError([error description]); + [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Change failed", @"Message to show when Publicize globally shared setting failed")]; + [weakSelf.tableView reloadData]; + }]; } - (void)reconnectPublicizeConnection { - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; - __weak __typeof(self) weakSelf = self; if (self.helper == nil) { - [sharingService syncPublicizeServicesForBlog:self.blog - success:^{ - [[weakSelf helper] reconnectPublicizeConnection:weakSelf.publicizeConnection]; - } - failure:^(NSError * _Nullable error) { - [WPError showNetworkingAlertWithError:error]; - }]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service syncServicesFor:self.blog success:^{ + [[weakSelf helper] reconnectPublicizeConnection:weakSelf.publicizeConnection]; + } failure:^(NSError * _Nullable error) { + [WPError showNetworkingAlertWithError:error]; + }]; } else { [self.helper reconnectPublicizeConnection:weakSelf.publicizeConnection]; } @@ -254,8 +247,8 @@ - (void)reconnectPublicizeConnection - (void)disconnectPublicizeConnection { - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; - [sharingService deletePublicizeConnectionForBlog:self.blog pubConn:self.publicizeConnection success:nil failure:^(NSError *error) { + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + [service deleteConnectionFor:self.blog pubConn:self.publicizeConnection success:nil failure:^(NSError *error) { DDLogError([error description]); [SVProgressHUD showDismissibleErrorWithStatus:NSLocalizedString(@"Disconnect failed", @"Message to show when Publicize disconnect failed")]; }]; diff --git a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m index 7ee4c37807f0..205f867d1cef 100644 --- a/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m +++ b/WordPress/Classes/ViewRelated/Blog/Sharing/SharingViewController.m @@ -406,9 +406,9 @@ -(void)showConnectionError - (void)syncPublicizeServices { - SharingService *sharingService = [[SharingService alloc] initWithContextManager:[ContextManager sharedInstance]]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; - [sharingService syncPublicizeServicesForBlog:self.blog success:^{ + [service syncServicesFor:self.blog success:^{ [weakSelf syncConnections]; } failure:^(NSError * __unused error) { if (!ReachabilityUtils.isInternetReachable) { @@ -422,9 +422,9 @@ - (void)syncPublicizeServices - (void)syncConnections { - SharingSyncService *sharingService = [[SharingSyncService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; + JetpackPublicizeService *service = [[JetpackPublicizeService alloc] initWithCoreDataStack:[ContextManager sharedInstance]]; __weak __typeof__(self) weakSelf = self; - [sharingService syncPublicizeConnectionsForBlog:self.blog success:^{ + [service syncConnectionsFor:self.blog success:^{ [weakSelf refreshPublicizers]; } failure:^(NSError * __unused error) { if (!ReachabilityUtils.isInternetReachable) {