diff --git a/SignalServiceKit/src/Profiles/OWSProfilesManager.h b/SignalServiceKit/src/Profiles/OWSProfilesManager.h index 21f4b1446..fe937cb0c 100644 --- a/SignalServiceKit/src/Profiles/OWSProfilesManager.h +++ b/SignalServiceKit/src/Profiles/OWSProfilesManager.h @@ -17,6 +17,14 @@ extern NSString *const kNSNotificationName_LocalProfileDidChange; - (nullable UIImage *)localProfileAvatarImage; +// This method is used to update the "local profile" state on the client +// and the service. Client state is only updated if service state is +// successfully updated. +- (void)setLocalProfileName:(nullable NSString *)localProfileName + localProfileAvatarImage:(nullable UIImage *)localProfileAvatarImage + success:(void (^)())successBlock + failure:(void (^)())failureBlock; + @end NS_ASSUME_NONNULL_END diff --git a/SignalServiceKit/src/Profiles/OWSProfilesManager.m b/SignalServiceKit/src/Profiles/OWSProfilesManager.m index 720844624..7d2c44650 100644 --- a/SignalServiceKit/src/Profiles/OWSProfilesManager.m +++ b/SignalServiceKit/src/Profiles/OWSProfilesManager.m @@ -8,16 +8,79 @@ #import "TSStorageManager.h" #import "TextSecureKitEnv.h" +#import "TSYapDatabaseObject.h" + NS_ASSUME_NONNULL_BEGIN +@class TSThread; + +@interface AvatarMetadata : TSYapDatabaseObject + +// This filename is relative to OWSProfilesManager.profileAvatarsDirPath. +@property (nonatomic, readonly) NSString *fileName; +@property (nonatomic, readonly) NSString *avatarUrl; +@property (nonatomic, readonly) NSString *avatarDigest; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +#pragma mark - + +@implementation AvatarMetadata + ++ (NSString *)collection +{ + return @"AvatarMetadata"; +} + +- (instancetype)initWithFileName:(NSString *)fileName + avatarUrl:(NSString *)avatarUrl + avatarDigest:(NSString *)avatarDigest +{ + // TODO: Local filenames for avatars are guaranteed to be unique. + self = [super initWithUniqueId:fileName]; + + if (!self) { + return self; + } + + OWSAssert(fileName.length > 0); + OWSAssert(avatarUrl.length > 0); + OWSAssert(avatarDigest.length > 0); + _fileName = fileName; + _avatarUrl = avatarUrl; + _avatarDigest = avatarDigest; + + return self; +} + + +#pragma mark - NSObject + +- (BOOL)isEqual:(AvatarMetadata *)other +{ + return ([other isKindOfClass:[AvatarMetadata class]] && [self.fileName isEqualToString:other.fileName] && + [self.avatarUrl isEqualToString:other.avatarUrl] && [self.avatarDigest isEqualToString:other.avatarDigest]); +} + +- (NSUInteger)hash +{ + return self.fileName.hash ^ self.avatarUrl.hash ^ self.avatarDigest.hash; +} + +@end + +#pragma mark - + NSString *const kNSNotificationName_LocalProfileDidChange = @"kNSNotificationName_LocalProfileDidChange"; NSString *const kOWSProfilesManager_Collection = @"kOWSProfilesManager_Collection"; // This key is used to persist the local user's profile key. NSString *const kOWSProfilesManager_LocalProfileKey = @"kOWSProfilesManager_LocalProfileKey"; NSString *const kOWSProfilesManager_LocalProfileNameKey = @"kOWSProfilesManager_LocalProfileNameKey"; -NSString *const kOWSProfilesManager_LocalProfileAvatarFilenameKey - = @"kOWSProfilesManager_LocalProfileAvatarFilenameKey"; +NSString *const kOWSProfilesManager_LocalProfileAvatarMetadataKey + = @"kOWSProfilesManager_LocalProfileAvatarMetadataKey"; // TODO: static const NSInteger kProfileKeyLength = 16; @@ -29,9 +92,11 @@ static const NSInteger kProfileKeyLength = 16; @property (atomic, readonly, nullable) NSData *localProfileKey; +// These properties should only be mutated on the main thread, +// but they may be accessed on other threads. @property (atomic, nullable) NSString *localProfileName; +@property (atomic, nullable) AvatarMetadata *localProfileAvatarMetadata; @property (atomic, nullable) UIImage *localProfileAvatarImage; -@property (atomic) BOOL hasLoadedLocalProfile; @end @@ -122,38 +187,215 @@ static const NSInteger kProfileKeyLength = 16; #pragma mark - Local Profile +// This method is use to update client "local profile" state. +- (void)updateLocalProfileName:(nullable NSString *)localProfileName + localProfileAvatarImage:(nullable UIImage *)localProfileAvatarImage + localProfileAvatarMetadata:(nullable AvatarMetadata *)localProfileAvatarMetadata +{ + OWSAssert([NSThread isMainThread]); + + // The avatar image and filename should both be set, or neither should be set. + if (!localProfileAvatarMetadata && localProfileAvatarImage) { + OWSFail(@"Missing avatar metadata."); + localProfileAvatarImage = nil; + } + if (localProfileAvatarMetadata && !localProfileAvatarImage) { + OWSFail(@"Missing avatar image."); + localProfileAvatarMetadata = nil; + } + + self.localProfileName = localProfileName; + self.localProfileAvatarImage = localProfileAvatarImage; + self.localProfileAvatarMetadata = localProfileAvatarMetadata; + + if (localProfileName) { + [self.storageManager setObject:localProfileName + forKey:kOWSProfilesManager_LocalProfileNameKey + inCollection:kOWSProfilesManager_Collection]; + } else { + [self.storageManager removeObjectForKey:kOWSProfilesManager_LocalProfileNameKey + inCollection:kOWSProfilesManager_Collection]; + } + if (localProfileAvatarMetadata) { + [self.storageManager setObject:localProfileAvatarMetadata + forKey:kOWSProfilesManager_LocalProfileAvatarMetadataKey + inCollection:kOWSProfilesManager_Collection]; + } else { + [self.storageManager removeObjectForKey:kOWSProfilesManager_LocalProfileAvatarMetadataKey + inCollection:kOWSProfilesManager_Collection]; + } + + [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_LocalProfileDidChange + object:nil + userInfo:nil]; +} + +- (void)setLocalProfileName:(nullable NSString *)localProfileName + localProfileAvatarImage:(nullable UIImage *)localProfileAvatarImage + success:(void (^)())successBlock + failure:(void (^)())failureBlock +{ + OWSAssert([NSThread isMainThread]); + OWSAssert(successBlock); + OWSAssert(failureBlock); + + // The final steps are to: + // + // * Try to update the service. + // * Update client state on success. + void (^tryToUpdateService)(AvatarMetadata *_Nullable) = ^(AvatarMetadata *_Nullable avatarMetadata) { + [self updateProfileOnService:localProfileName + avatarMetadata:avatarMetadata + success:^{ + [self updateLocalProfileName:localProfileName + localProfileAvatarImage:localProfileAvatarImage + localProfileAvatarMetadata:avatarMetadata]; + successBlock(); + } + failure:^{ + failureBlock(); + }]; + }; + + // If we have a new avatar image, we must first: + // + // * Encode it to JPEG. + // * Write it to disk. + // * Upload it to service. + if (localProfileAvatarImage) { + if (self.localProfileAvatarMetadata && self.localProfileAvatarImage == localProfileAvatarImage) { + DDLogVerbose(@"%@ Updating local profile on service with unchanged avatar.", self.tag); + // If the avatar hasn't changed, reuse the existing metadata. + tryToUpdateService(self.localProfileAvatarMetadata); + } else { + DDLogVerbose(@"%@ Updating local profile on service with new avatar.", self.tag); + [self writeAvatarToDisk:localProfileAvatarImage + success:^(NSData *data, NSString *fileName) { + [self uploadAvatarToService:data + fileName:fileName + success:^(AvatarMetadata *avatarMetadata) { + tryToUpdateService(avatarMetadata); + } + failure:^{ + failureBlock(); + }]; + } + failure:^{ + failureBlock(); + }]; + } + } else { + DDLogVerbose(@"%@ Updating local profile on service with no avatar.", self.tag); + tryToUpdateService(nil); + } +} + +- (void)writeAvatarToDisk:(UIImage *)avatar + success:(void (^)(NSData *data, NSString *fileName))successBlock + failure:(void (^)())failureBlock +{ + OWSAssert(avatar); + OWSAssert(successBlock); + OWSAssert(failureBlock); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + if (avatar) { + NSData *_Nullable data = UIImageJPEGRepresentation(avatar, 1.f); + OWSAssert(data); + if (data) { + NSString *fileName = [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpg"]; + NSString *filePath = [self.profileAvatarsDirPath stringByAppendingPathComponent:fileName]; + BOOL success = [data writeToFile:filePath atomically:YES]; + OWSAssert(success); + if (success) { + successBlock(data, fileName); + return; + } + } + } + failureBlock(); + }); +} + +// TODO: The exact API & encryption scheme for avatars is not yet settled. +- (void)uploadAvatarToService:(NSData *)data + fileName:(NSString *)fileName + success:(void (^)(AvatarMetadata *avatarMetadata))successBlock + failure:(void (^)())failureBlock +{ + OWSAssert(data.length > 0); + OWSAssert(fileName.length > 0); + OWSAssert(successBlock); + OWSAssert(failureBlock); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // TODO: + NSString *avatarUrl = @"avatarUrl"; + NSString *avatarDigest = @"digest"; + AvatarMetadata *avatarMetadata = + [[AvatarMetadata alloc] initWithFileName:fileName avatarUrl:avatarUrl avatarDigest:avatarDigest]; + if (YES) { + successBlock(avatarMetadata); + return; + } + failureBlock(); + }); +} + +// TODO: The exact API & encryption scheme for profiles is not yet settled. +- (void)updateProfileOnService:(nullable NSString *)localProfileName + avatarMetadata:(nullable AvatarMetadata *)avatarMetadata + success:(void (^)())successBlock + failure:(void (^)())failureBlock +{ + OWSAssert(successBlock); + OWSAssert(failureBlock); + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + // TODO: + if (YES) { + successBlock(); + return; + } + failureBlock(); + }); +} + - (void)loadLocalProfileAsync { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *_Nullable localProfileName = [self.storageManager objectForKey:kOWSProfilesManager_LocalProfileNameKey inCollection:kOWSProfilesManager_Collection]; - NSString *_Nullable localProfileAvatarFilename = - [self.storageManager objectForKey:kOWSProfilesManager_LocalProfileAvatarFilenameKey + AvatarMetadata *_Nullable localProfileAvatarMetadata = + [self.storageManager objectForKey:kOWSProfilesManager_LocalProfileAvatarMetadataKey inCollection:kOWSProfilesManager_Collection]; - UIImage *_Nullable localProfileAvatar = nil; - if (localProfileAvatarFilename) { - localProfileAvatar = [self loadProfileAvatarsWithFilename:localProfileAvatarFilename]; + UIImage *_Nullable localProfileAvatarImage = nil; + if (localProfileAvatarMetadata) { + localProfileAvatarImage = [self loadProfileAvatarsWithFilename:localProfileAvatarMetadata.fileName]; + if (!localProfileAvatarImage) { + localProfileAvatarMetadata = nil; + } } dispatch_async(dispatch_get_main_queue(), ^{ self.localProfileName = localProfileName; - self.localProfileAvatarImage = localProfileAvatar; - self.hasLoadedLocalProfile = YES; + self.localProfileAvatarImage = localProfileAvatarImage; + self.localProfileAvatarMetadata = localProfileAvatarMetadata; - if (localProfileAvatar || localProfileName) { - [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_LocalProfileDidChange - object:nil - userInfo:nil]; - } + [[NSNotificationCenter defaultCenter] postNotificationName:kNSNotificationName_LocalProfileDidChange + object:nil + userInfo:nil]; }); }); } #pragma mark - Avatar Disk Cache -- (nullable UIImage *)loadProfileAvatarsWithFilename:(NSString *)filename +- (nullable UIImage *)loadProfileAvatarsWithFilename:(NSString *)fileName { - NSString *filePath = [self.profileAvatarsDirPath stringByAppendingPathComponent:filename]; + OWSAssert(fileName.length > 0); + + NSString *filePath = [self.profileAvatarsDirPath stringByAppendingPathComponent:fileName]; UIImage *_Nullable image = [UIImage imageWithContentsOfFile:filePath]; return image; }