Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/remote content hash #296

Open
wants to merge 10 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion WordPressKit.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = "WordPressKit"
s.version = "4.19-beta.2"
s.version = "4.19-beta.3"
s.summary = "WordPressKit offers a clean and simple WordPress.com and WordPress.org API."

s.description = <<-DESC
Expand Down
12 changes: 12 additions & 0 deletions WordPressKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
objects = {

/* Begin PBXBuildFile section */
14233D0F253DAEF7001C9E2E /* RemotePostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */; };
14233D35253F6B51001C9E2E /* SHAHasher.m in Sources */ = {isa = PBXBuildFile; fileRef = 14233D34253F6B51001C9E2E /* SHAHasher.m */; };
14C42DBD2545EE3B0091712B /* SHAHasher.h in Headers */ = {isa = PBXBuildFile; fileRef = 14233D39253F6B99001C9E2E /* SHAHasher.h */; settings = {ATTRIBUTES = (Public, ); }; };
1769DEAA24729AFF00F42EFC /* HomepageSettingsServiceRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */; };
17BF9A6C20C7DC3300BF57D2 /* reader-site-search-success.json in Resources */ = {isa = PBXBuildFile; fileRef = 17BF9A6B20C7DC3300BF57D2 /* reader-site-search-success.json */; };
17BF9A7220C7E18200BF57D2 /* reader-site-search-success-hasmore.json in Resources */ = {isa = PBXBuildFile; fileRef = 17BF9A6D20C7E18100BF57D2 /* reader-site-search-success-hasmore.json */; };
Expand Down Expand Up @@ -538,6 +541,9 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePostTests.swift; sourceTree = "<group>"; };
14233D34253F6B51001C9E2E /* SHAHasher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SHAHasher.m; sourceTree = "<group>"; };
14233D39253F6B99001C9E2E /* SHAHasher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SHAHasher.h; sourceTree = "<group>"; };
1769DEA924729AFF00F42EFC /* HomepageSettingsServiceRemote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsServiceRemote.swift; sourceTree = "<group>"; };
17BF9A6B20C7DC3300BF57D2 /* reader-site-search-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "reader-site-search-success.json"; sourceTree = "<group>"; };
17BF9A6D20C7E18100BF57D2 /* reader-site-search-success-hasmore.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "reader-site-search-success-hasmore.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1165,6 +1171,7 @@
9AB6D649218727D60008F274 /* PostServiceRemoteRESTRevisionsTest.swift */,
740B23D01F17F6BB00067A2A /* PostServiceRemoteRESTTests.m */,
740B23D11F17F6BB00067A2A /* PostServiceRemoteXMLRPCTests.swift */,
14233D0E253DAEF7001C9E2E /* RemotePostTests.swift */,
);
name = Post;
sourceTree = "<group>";
Expand Down Expand Up @@ -1881,6 +1888,8 @@
93C674EF1EE8351E00BFAF05 /* NSMutableDictionary+Helpers.h */,
93C674F01EE8351E00BFAF05 /* NSMutableDictionary+Helpers.m */,
9F4E51FF2088E38200424676 /* ObjectValidation.swift */,
14233D39253F6B99001C9E2E /* SHAHasher.h */,
14233D34253F6B51001C9E2E /* SHAHasher.m */,
);
name = Utility;
sourceTree = "<group>";
Expand Down Expand Up @@ -2018,6 +2027,7 @@
742362E01F1025B400BD0A7F /* RemoteMenuItem.h in Headers */,
7430C9B51F1927C50051B8E6 /* RemoteReaderSiteInfo.h in Headers */,
7430C9A71F1927180051B8E6 /* ReaderTopicServiceRemote.h in Headers */,
14C42DBD2545EE3B0091712B /* SHAHasher.h in Headers */,
7430C9B71F1927C50051B8E6 /* RemoteReaderTopic.h in Headers */,
7430C9B31F1927C50051B8E6 /* RemoteReaderSite.h in Headers */,
7430C9B11F1927C50051B8E6 /* RemoteReaderPost.h in Headers */,
Expand Down Expand Up @@ -2576,6 +2586,7 @@
74650F741F0EA1E200188EDB /* RemoteGravatarProfile.swift in Sources */,
40E7FEB4221063480032834E /* StatsTodayInsight.swift in Sources */,
436D563C2118E18D00CEAA33 /* State.swift in Sources */,
14233D35253F6B51001C9E2E /* SHAHasher.m in Sources */,
439A44DA2107C93000795ED7 /* RemotePlan_ApiVersion1_3.swift in Sources */,
93BD27811EE73944002BB00B /* WordPressOrgXMLRPCApi.swift in Sources */,
439A44D62107C66A00795ED7 /* JSONDecoderExtension.swift in Sources */,
Expand Down Expand Up @@ -2654,6 +2665,7 @@
93188D221F2264E60028ED4D /* TaxonomyServiceRemoteRESTTests.m in Sources */,
F194E1232417ED9F00874408 /* AtomicAuthenticationServiceRemoteTests.swift in Sources */,
74FC6F3B1F191BB400112505 /* NotificationSyncServiceRemoteTests.swift in Sources */,
14233D0F253DAEF7001C9E2E /* RemotePostTests.swift in Sources */,
731BA83821DECD97000FDFCD /* SiteCreationResponseDecodingTests.swift in Sources */,
9A2D0B2B225E0E22009E585F /* JetpackServiceRemoteTests.swift in Sources */,
74FA25F71F1FDA200044BC54 /* MediaServiceRemoteRESTTests.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions WordPressKit/RemotePost.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extern NSString * const PostStatusDeleted;

@interface RemotePost : NSObject
- (id)initWithSiteID:(NSNumber *)siteID status:(NSString *)status title:(NSString *)title content:(NSString *)content;
- (NSString *)contentHash;

@property (nonatomic, strong) NSNumber *postID;
@property (nonatomic, strong) NSNumber *siteID;
Expand Down
45 changes: 45 additions & 0 deletions WordPressKit/RemotePost.m
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import "RemotePost.h"
#import <objc/runtime.h>
#import "SHAHasher.h"

NSString * const PostStatusDraft = @"draft";
NSString * const PostStatusPending = @"pending";
Expand Down Expand Up @@ -47,4 +48,48 @@ - (NSDictionary *)debugProperties {
return [NSDictionary dictionaryWithDictionary:debugProperties];
}

/// A hash used to determine if the remote content has changed.
///
/// This hash must remain constant regardless of iOS version, app restarts or instances used. `Hasher` or NSObject's `hash` were not used for these reasons.
///
/// `dateModified` is not included within the hash as it is prone to change wihout the content having been changed and is the reason this hash is necessary.
///
/// `autosave` properties are intentionally omitted as remote autosaves are always discarded in favor of local autosaves (aka `revision`s)
///
/// - Note: At the time of writing the backend will occasionally create updates that neither come from autosaves or user initiated saves, these will modify the hash
/// and at present are treated as genuine updates as they are triggered by the user changing their posts content.
- (NSString *)contentHash
{
NSString *hashedContents = [NSString stringWithFormat:@"%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@%@/%@/%@",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering we have support to hash each component in the methods below, and then combine the hash of each component (through combineHashes), is there a reason behind the choice of using a big string concatenating all of the post elements?

Copy link

@palringo-dec palringo-dec Oct 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance mostly.

Hashing every property will likely minimize collisions but it took ~ 0.1s when I performance tested this. I fear this could result in a significant delay when dealing with many posts.

Property hash performance

image

For an AbstractPost upload I don't think this is a problem as a post upload isn't an action that blocks the user. However for downloading a RemotePost update this could result in real user delays as the non-arrival of this update will block them from continued use of the app.

ConcatenatedStringHash Performance

After concatenating the strings we're down to < 0.01s

image

I'm still a little bit concerned that this has the potential to be a bit too slow if we're going to be checking large numbers of posts each update. The swift algorithm I wrote earlier this month just performs a bitwise shift operation, this would be much faster than the others if it prove necessary.

Let me know your thoughts, perhaps I've missed something somewhere.
How are posts currently downloaded when in large numbers? Do we download them in batches?

self.postID.stringValue,
self.siteID.stringValue,
self.authorAvatarURL,
self.authorDisplayName,
self.authorEmail,
self.authorURL,
self.authorID.stringValue,
self.date.description,
self.title,
self.URL.absoluteString,
self.shortURL.absoluteString,
self.content,
self.excerpt,
self.slug,
self.suggestedSlug,
self.status,
self.parentID.stringValue,
self.postThumbnailID.stringValue,
self.postThumbnailPath,
self.type,
self.format,
self.commentCount.stringValue,
self.likeCount.stringValue,
[self.tags componentsJoinedByString:@""],
self.pathForDisplayImage,
self.isStickyPost.stringValue,
self.isFeaturedImageChanged ? @"1" : @"2"];
NSData *hashData = [SHAHasher hashForString:hashedContents];
return [SHAHasher hexStringFromData:hashData];
}

@end
18 changes: 18 additions & 0 deletions WordPressKit/SHAHasher.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// SHAHasher.h
// WordPressKit
//
// Created by Declan McKenna on 20/10/2020.
// Copyright © 2020 Automattic Inc. All rights reserved.
//
#import <Foundation/Foundation.h>

@interface SHAHasher : NSObject
+ (NSString *)combineHashes:(NSArray<NSData *>*) hashArray;
+ (NSData *)hashForStringArray:(NSArray *) array;
+ (NSData *)hashForString:(NSString *) string;
+ (NSData *)hashForNSInteger:(NSInteger)integer;
+ (NSData *)hashForDouble:(double)dbl;
+ (NSData *)hashForBool:(BOOL)boolean;
+ (NSString *)hexStringFromData:(NSData *)data;
@end
81 changes: 81 additions & 0 deletions WordPressKit/SHAHasher.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// SHAHasher.m
// WordPressKit
//
// Created by Declan McKenna on 20/10/2020.
// Copyright © 2020 Automattic Inc. All rights reserved.
//
#import "SHAHasher.h"
#import <Foundation/Foundation.h>
#import <CommonCrypto/CommonDigest.h>
@implementation SHAHasher

+ (NSString *)combineHashes:(NSArray<NSData *>*) hashArray
{
NSMutableData *mutableData = [NSMutableData data];
for (NSData *hash in hashArray) {
[mutableData appendData:hash];
}

unsigned char finalDigest[CC_SHA256_DIGEST_LENGTH];

CC_SHA256(mutableData.bytes, (CC_LONG)mutableData.length, finalDigest);

return [self hexStringFromData:[NSData dataWithBytes:finalDigest length:CC_SHA256_DIGEST_LENGTH]];
}

+ (NSData *)hashForStringArray:(NSArray *) array {
NSString *joinedArrayString = [array componentsJoinedByString:@""];
return [self hashForString:joinedArrayString];
}

+ (NSData *)hashForString:(NSString *) string {
if (!string) {
return [[NSMutableData dataWithLength:CC_SHA256_DIGEST_LENGTH] copy];
}

NSData *encodedBytes = [string dataUsingEncoding:NSUTF8StringEncoding];
unsigned char digest[CC_SHA256_DIGEST_LENGTH];

CC_SHA256(encodedBytes.bytes, (CC_LONG)encodedBytes.length, digest);

return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
}

+ (NSData *)hashForNSInteger:(NSInteger)integer {
unsigned char digest[CC_SHA256_DIGEST_LENGTH];

CC_SHA256(&integer, sizeof(integer), digest);

return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
}

+ (NSData *)hashForDouble:(double)dbl {
unsigned char digest[CC_SHA256_DIGEST_LENGTH];

CC_SHA256(&dbl, sizeof(dbl), digest);

return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
}

+ (NSData *)hashForBool:(BOOL)boolean {
unsigned char digest[CC_SHA256_DIGEST_LENGTH];

CC_SHA256(&boolean, sizeof(boolean), digest);

return [NSData dataWithBytes:digest length:CC_SHA256_DIGEST_LENGTH];
}

+ (NSString *)hexStringFromData:(NSData *)data {
NSMutableString *mutableString = [NSMutableString string];

const char *hashBytes = [data bytes];

for (int i = 0; i < data.length; i++) {
[mutableString appendFormat:@"%02.2hhx", hashBytes[i]];
}

return [mutableString copy];
}

@end
1 change: 1 addition & 0 deletions WordPressKit/WordPressKit.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,4 @@ FOUNDATION_EXPORT const unsigned char WordPressKitVersionString[];

#import <WordPressKit/NSDate+WordPressJSON.h>
#import <WordPressKit/NSString+MD5.h>
#import <WordPressKit/SHAHasher.h>
84 changes: 84 additions & 0 deletions WordPressKitTests/RemotePostTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// RemotePostTests.swift
// WordPressKitTests
//
// Created by Declan McKenna on 19/10/2020.
// Copyright © 2020 Automattic Inc. All rights reserved.
//

import XCTest
@testable import WordPressKit

class RemotePostTests: XCTestCase {

func testHashWithNilValues() {
XCTAssertEqual(RemotePost().contentHash(), RemotePost().contentHash())
}

func testHashWithNilValuesIsPersistent() {
let expectedHash = "72bcdd41f59ecd51f66ada667342a04765ff8f17997a4d48ea708e6eabbf5580"
XCTAssertEqual(RemotePost().contentHash(), expectedHash)
}

func testRemotePostHashIsSameForSameContent() {
let post = noNullPropertyPost
let identicalPost = noNullPropertyPost
XCTAssertEqual(post.contentHash(), identicalPost.contentHash())
}

func testRemotePostHashDiffersForDifferentContent() {
let post = noNullPropertyPost
let modifiedPost = noNullPropertyPost
modifiedPost.tags.append("new tag")
XCTAssertNotEqual(post.contentHash(), modifiedPost.contentHash())
}

func testRemotePostHashIsPersistent() {
let post = noNullPropertyPost
let expectedHash = "729a3df7c916699c5efb548dc4f53f43dec11d5516dd63ff6787c81904d464f1"
XCTAssertEqual(post.contentHash(), expectedHash)
}

func testAutosaveDoesntAlterHash() {
let post = RemotePost()
let hash = RemotePost().contentHash()
post.autosave = RemotePostAutosave()
let autosavedPostHash = post.contentHash()
XCTAssertEqual(hash, autosavedPostHash)
}
}

private extension RemotePostTests {
/// Remote post with all properties set to non null to ensure hash is consistent for all fields
var noNullPropertyPost: RemotePost {
let remotePost = RemotePost()
remotePost.postID = 90210
remotePost.siteID = 101
remotePost.authorAvatarURL = "www.test.com"
remotePost.authorDisplayName = "jk"
remotePost.authorEmail = "[email protected]"
remotePost.authorURL = "swiftdec.com"
remotePost.authorID = 9001
remotePost.date = Date(timeIntervalSince1970: 0)
remotePost.title = "Lorem Ipsum"
remotePost.url = URL(string: "lemonparty.com")
remotePost.shortURL = URL(string: "www.why.com")
remotePost.content = "Dolor Sit Amet"
remotePost.excerpt = "...."
remotePost.slug = "lorem-ipsum"
remotePost.suggestedSlug = "~!!"
remotePost.status = "draft"
remotePost.parentID = 42
remotePost.postThumbnailID = 420
remotePost.postThumbnailPath = "Arakis"
remotePost.type = ""
remotePost.format = ""
remotePost.commentCount = 666
remotePost.likeCount = 555
remotePost.tags = ["lorem,ipsum,test"]
remotePost.pathForDisplayImage = "!.com"
remotePost.isStickyPost = true
remotePost.isFeaturedImageChanged = false
return remotePost
}
}