session-ios/SignalUtilitiesKit/To Do/OWSProfileManager.m

1077 lines
42 KiB
Objective-C

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "OWSProfileManager.h"
#import "Environment.h"
#import "OWSUserProfile.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import "UIUtil.h"
#import <SessionUtilitiesKit/AppContext.h>
#import <SessionMessagingKit/AppReadiness.h>
#import <SessionUtilitiesKit/MIMETypeUtil.h>
#import <SessionUtilitiesKit/NSData+Image.h>
#import <SessionUtilitiesKit/NSNotificationCenter+OWS.h>
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SessionMessagingKit/OWSBlockingManager.h>
#import <SessionUtilitiesKit/OWSFileSystem.h>
#import <SignalUtilitiesKit/OWSPrimaryStorage+Loki.h>
#import <SessionMessagingKit/SSKEnvironment.h>
#import <SessionMessagingKit/TSAccountManager.h>
#import <SessionMessagingKit/TSGroupThread.h>
#import <SessionMessagingKit/TSThread.h>
#import <SessionUtilitiesKit/TSYapDatabaseObject.h>
#import <SessionUtilitiesKit/UIImage+OWS.h>
#import <SessionMessagingKit/YapDatabaseConnection+OWS.h>
NS_ASSUME_NONNULL_BEGIN
NSString *const kNSNotificationName_ProfileWhitelistDidChange = @"kNSNotificationName_ProfileWhitelistDidChange";
NSString *const kOWSProfileManager_UserWhitelistCollection = @"kOWSProfileManager_UserWhitelistCollection";
NSString *const kOWSProfileManager_GroupWhitelistCollection = @"kOWSProfileManager_GroupWhitelistCollection";
NSString *const kNSNotificationName_ProfileKeyDidChange = @"kNSNotificationName_ProfileKeyDidChange";
// The max bytes for a user's profile name, encoded in UTF8.
// Before encrypting and submitting we NULL pad the name data to this length.
const NSUInteger kOWSProfileManager_NameDataLength = 26;
const NSUInteger kOWSProfileManager_MaxAvatarDiameter = 640;
typedef void (^ProfileManagerFailureBlock)(NSError *error);
@interface OWSProfileManager ()
@property (nonatomic, readonly) YapDatabaseConnection *dbConnection;
// This property can be accessed on any thread, while synchronized on self.
@property (atomic, readonly) OWSUserProfile *localUserProfile;
// This property can be accessed on any thread, while synchronized on self.
@property (atomic, readonly) NSCache<NSString *, UIImage *> *profileAvatarImageCache;
// This property can be accessed on any thread, while synchronized on self.
@property (atomic, readonly) NSMutableSet<NSString *> *currentAvatarDownloads;
@end
#pragma mark -
// Access to most state should happen while synchronized on the profile manager.
// Writes should happen off the main thread, wherever possible.
@implementation OWSProfileManager
@synthesize localUserProfile = _localUserProfile;
+ (instancetype)sharedManager
{
return SSKEnvironment.shared.profileManager;
}
- (instancetype)initWithPrimaryStorage:(OWSPrimaryStorage *)primaryStorage
{
self = [super init];
if (!self) {
return self;
}
OWSAssertIsOnMainThread();
OWSAssertDebug(primaryStorage);
_dbConnection = primaryStorage.newDatabaseConnection;
_profileAvatarImageCache = [NSCache new];
_currentAvatarDownloads = [NSMutableSet new];
OWSSingletonAssert();
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[self rotateLocalProfileKeyIfNecessary];
}];
[self observeNotifications];
return self;
}
- (void)dealloc
{
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)observeNotifications
{
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(blockListDidChange:)
name:kNSNotificationName_BlockListDidChange
object:nil];
}
#pragma mark - Dependencies
- (TSAccountManager *)tsAccountManager
{
return TSAccountManager.sharedInstance;
}
- (OWSIdentityManager *)identityManager
{
return SSKEnvironment.shared.identityManager;
}
- (OWSBlockingManager *)blockingManager
{
return SSKEnvironment.shared.blockingManager;
}
#pragma mark - User Profile Accessor
- (void)ensureLocalProfileCached
{
// Since localUserProfile can create a transaction, we want to make sure it's not called for the first
// time unexpectedly (e.g. in a nested transaction.)
__unused OWSUserProfile *profile = [self localUserProfile];
}
#pragma mark - Local Profile
- (OWSUserProfile *)localUserProfile
{
if (_localUserProfile) { return _localUserProfile; }
__block OWSUserProfile *userProfile;
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
userProfile = [self getLocalUserProfileWithTransaction:transaction];
}];
return userProfile;
}
- (OWSUserProfile *)getLocalUserProfileWithTransaction:(YapDatabaseReadWriteTransaction *)transaction
{
@synchronized(self)
{
if (!_localUserProfile) {
_localUserProfile = [OWSUserProfile getOrBuildUserProfileForRecipientId:kLocalProfileUniqueId transaction:transaction];
}
}
OWSAssertDebug(_localUserProfile.profileKey);
return _localUserProfile;
}
- (BOOL)localProfileExists
{
return [OWSUserProfile localUserProfileExists:self.dbConnection];
}
- (OWSAES256Key *)localProfileKey
{
OWSAssertDebug(self.localUserProfile.profileKey.keyData.length == kAES256_KeyByteLength);
return self.localUserProfile.profileKey;
}
- (BOOL)hasLocalProfile
{
return (self.localProfileName.length > 0 || self.localProfileAvatarImage != nil);
}
- (nullable NSString *)localProfileName
{
return self.localUserProfile.profileName;
}
- (nullable UIImage *)localProfileAvatarImage
{
return [self loadProfileAvatarWithFilename:self.localUserProfile.avatarFileName];
}
- (nullable NSData *)localProfileAvatarData
{
NSString *_Nullable filename = self.localUserProfile.avatarFileName;
if (filename.length < 1) {
return nil;
}
return [self loadProfileDataWithFilename:filename];
}
- (void)updateLocalProfileName:(nullable NSString *)profileName
avatarImage:(nullable UIImage *)avatarImage
success:(void (^)(void))successBlockParameter
failure:(void (^)(NSError *))failureBlockParameter
requiresSync:(BOOL)requiresSync
{
OWSAssertDebug(successBlockParameter);
OWSAssertDebug(failureBlockParameter);
// Ensure that the success and failure blocks are called on the main thread.
void (^failureBlock)(NSError *) = ^(NSError *error) {
OWSLogError(@"Updating service with profile failed.");
dispatch_async(dispatch_get_main_queue(), ^{
failureBlockParameter(error);
});
};
void (^successBlock)(void) = ^{
OWSLogInfo(@"Successfully updated service with profile.");
dispatch_async(dispatch_get_main_queue(), ^{
successBlockParameter();
});
};
// The final steps are to:
//
// * Try to update the service.
// * Update client state on success.
void (^tryToUpdateService)(NSString *_Nullable, NSString *_Nullable) = ^(
NSString *_Nullable avatarUrlPath, NSString *_Nullable avatarFileName) {
[self updateServiceWithProfileName:profileName
avatarUrl:avatarUrlPath
success:^{
OWSUserProfile *userProfile = self.localUserProfile;
OWSAssertDebug(userProfile);
[userProfile updateWithProfileName:profileName
avatarUrlPath:avatarUrlPath
avatarFileName:avatarFileName
dbConnection:self.dbConnection
completion:^{
if (avatarFileName) {
[self updateProfileAvatarCache:avatarImage filename:avatarFileName];
}
successBlock();
}];
}
failure:^(NSError *error) {
failureBlock(error);
}];
};
OWSUserProfile *userProfile = self.localUserProfile;
OWSAssertDebug(userProfile);
if (avatarImage) {
// If we have a new avatar image, we must first:
//
// * Encode it to JPEG.
// * Write it to disk.
// * Encrypt it
// * Upload it to asset service
// * Send asset service info to Signal Service
OWSLogVerbose(@"Updating local profile on service with new avatar.");
[self writeAvatarToDisk:avatarImage
success:^(NSData *data, NSString *fileName) {
[self uploadAvatarToService:data
success:^(NSString *_Nullable avatarUrlPath) {
tryToUpdateService(avatarUrlPath, fileName);
}
failure:^(NSError *error) {
failureBlock(error);
}];
}
failure:^(NSError *error) {
failureBlock(error);
}];
} else if (userProfile.avatarUrlPath) {
OWSLogVerbose(@"Updating local profile on service with cleared avatar.");
[self uploadAvatarToService:nil
success:^(NSString *_Nullable avatarUrlPath) {
tryToUpdateService(nil, nil);
}
failure:^(NSError *error) {
failureBlock(error);
}];
} else {
OWSLogVerbose(@"Updating local profile on service with no avatar.");
tryToUpdateService(nil, nil);
}
}
- (nullable NSString *)profilePictureURL
{
return self.localUserProfile.avatarUrlPath;
}
- (void)writeAvatarToDisk:(UIImage *)avatar
success:(void (^)(NSData *data, NSString *fileName))successBlock
failure:(ProfileManagerFailureBlock)failureBlock {
OWSAssertDebug(avatar);
OWSAssertDebug(successBlock);
OWSAssertDebug(failureBlock);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
if (avatar) {
NSData *data = [self processedImageDataForRawAvatar:avatar];
OWSAssertDebug(data);
if (data) {
NSString *fileName = [self generateAvatarFilename];
NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName];
BOOL success = [data writeToFile:filePath atomically:YES];
OWSAssertDebug(success);
if (success) {
return successBlock(data, fileName);
}
}
}
failureBlock(OWSErrorWithCodeDescription(OWSErrorCodeAvatarWriteFailed, @"Avatar write failed."));
});
}
- (NSData *)processedImageDataForRawAvatar:(UIImage *)image
{
NSUInteger kMaxAvatarBytes = 5 * 1000 * 1000;
if (image.size.width != kOWSProfileManager_MaxAvatarDiameter
|| image.size.height != kOWSProfileManager_MaxAvatarDiameter) {
// To help ensure the user is being shown the same cropping of their avatar as
// everyone else will see, we want to be sure that the image was resized before this point.
OWSFailDebug(@"Avatar image should have been resized before trying to upload");
image = [image resizedImageToFillPixelSize:CGSizeMake(kOWSProfileManager_MaxAvatarDiameter,
kOWSProfileManager_MaxAvatarDiameter)];
}
NSData *_Nullable data = UIImageJPEGRepresentation(image, 0.95f);
if (data.length > kMaxAvatarBytes) {
// Our avatar dimensions are so small that it's incredibly unlikely we wouldn't be able to fit our profile
// photo. e.g. generating pure noise at our resolution compresses to ~200k.
OWSFailDebug(@"Suprised to find profile avatar was too large. Was it scaled properly? image: %@", image);
}
return data;
}
// If avatarData is nil, we are clearing the avatar.
- (void)uploadAvatarToService:(NSData *_Nullable)avatarData
success:(void (^)(NSString *_Nullable avatarUrlPath))successBlock
failure:(ProfileManagerFailureBlock)failureBlock {
OWSAssertDebug(successBlock);
OWSAssertDebug(failureBlock);
OWSAssertDebug(avatarData == nil || avatarData.length > 0);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// We always want to encrypt a profile with a new profile key
// This ensures that other users know that our profile picture was updated
OWSAES256Key *newProfileKey = [OWSAES256Key generateRandomKey];
if (avatarData) {
NSData *encryptedAvatarData = [self encryptProfileData:avatarData profileKey:newProfileKey];
OWSAssertDebug(encryptedAvatarData.length > 0);
AnyPromise *promise = [SNFileServerAPIV2 upload:encryptedAvatarData];
[promise.thenOn(dispatch_get_main_queue(), ^(NSString *fileID) {
NSString *downloadURL = [NSString stringWithFormat:@"%@/files/%@", SNFileServerAPIV2.server, fileID];
[NSUserDefaults.standardUserDefaults setObject:[NSDate new] forKey:@"lastProfilePictureUpload"];
[self.localUserProfile updateWithProfileKey:newProfileKey dbConnection:self.dbConnection completion:^{
successBlock(downloadURL);
}];
})
.catchOn(dispatch_get_main_queue(), ^(id result) {
// There appears to be a bug in PromiseKit that sometimes causes catchOn
// to be invoked with the fulfilled promise's value as the error. The below
// is a quick and dirty workaround.
if ([result isKindOfClass:NSString.class]) {
[self.localUserProfile updateWithProfileKey:newProfileKey dbConnection:self.dbConnection completion:^{
successBlock(result);
}];
} else {
failureBlock(result);
}
}) retainUntilComplete];
} else {
// Update our profile key and set the url to nil if avatar data is nil
[self.localUserProfile updateWithProfileKey:newProfileKey dbConnection:self.dbConnection completion:^{
successBlock(nil);
}];
}
});
}
- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName
avatarUrl:(nullable NSString *)avatarURL
success:(void (^)(void))successBlock
failure:(ProfileManagerFailureBlock)failureBlock {
successBlock();
}
- (void)updateServiceWithProfileName:(nullable NSString *)localProfileName avatarURL:(nullable NSString *)avatarURL {
[self updateServiceWithProfileName:localProfileName avatarUrl:avatarURL success:^{} failure:^(NSError * _Nonnull error) {}];
}
#pragma mark - Profile Key Rotation
- (nullable NSString *)groupKeyForGroupId:(NSData *)groupId {
NSString *groupIdKey = [groupId hexadecimalString];
return groupIdKey;
}
- (nullable NSData *)groupIdForGroupKey:(NSString *)groupKey {
NSMutableData *groupId = [NSMutableData new];
if (groupKey.length % 2 != 0) {
OWSFailDebug(@"Group key has unexpected length: %@ (%lu)", groupKey, (unsigned long)groupKey.length);
return nil;
}
for (NSUInteger i = 0; i + 2 <= groupKey.length; i += 2) {
NSString *_Nullable byteString = [groupKey substringWithRange:NSMakeRange(i, 2)];
if (!byteString) {
OWSFailDebug(@"Couldn't slice group key.");
return nil;
}
unsigned byteValue;
if (![[NSScanner scannerWithString:byteString] scanHexInt:&byteValue]) {
OWSFailDebug(@"Couldn't parse hex byte: %@.", byteString);
return nil;
}
if (byteValue > 0xff) {
OWSFailDebug(@"Invalid hex byte: %@ (%d).", byteString, byteValue);
return nil;
}
uint8_t byte = (uint8_t)(0xff & byteValue);
[groupId appendBytes:&byte length:1];
}
return [groupId copy];
}
- (void)rotateLocalProfileKeyIfNecessary {
[self rotateLocalProfileKeyIfNecessaryWithSuccess:^{ } failure:^(NSError *error) { }];
}
- (void)rotateLocalProfileKeyIfNecessaryWithSuccess:(dispatch_block_t)success
failure:(ProfileManagerFailureBlock)failure {
OWSAssertDebug(AppReadiness.isAppReady);
if (!self.tsAccountManager.isRegistered) {
success();
return;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSMutableSet<NSString *> *whitelistedRecipientIds = [NSMutableSet new];
NSMutableSet<NSData *> *whitelistedGroupIds = [NSMutableSet new];
[self.dbConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
[whitelistedRecipientIds
addObjectsFromArray:[transaction allKeysInCollection:kOWSProfileManager_UserWhitelistCollection]];
NSArray<NSString *> *whitelistedGroupKeys =
[transaction allKeysInCollection:kOWSProfileManager_GroupWhitelistCollection];
for (NSString *groupKey in whitelistedGroupKeys) {
NSData *_Nullable groupId = [self groupIdForGroupKey:groupKey];
if (!groupId) {
OWSFailDebug(@"Couldn't parse group key: %@.", groupKey);
continue;
}
[whitelistedGroupIds addObject:groupId];
// Note we don't add `group.recipientIds` to the `whitelistedRecipientIds`.
//
// Whenever we message a contact, be it in a 1:1 thread or in a group thread,
// we add them to the contact whitelist, so there's no reason to redundnatly
// add them here.
//
// Furthermore, doing so would cause the following problem:
// - Alice is in group Book Club
// - Add Book Club to your profile white list
// - Message Book Club, which also adds Alice to your profile whitelist.
// - Block Alice, but not Book Club
//
// Now, at this point we'd want to rotate our profile key once, since Alice has
// it via BookClub.
//
// However, after we did. The next time we check if we should rotate our profile
// key, adding all `group.recipientIds` to `whitelistedRecipientIds` here, would
// include Alice, and we'd rotate our profile key every time this method is called.
}
}];
NSString *_Nullable localNumber = [TSAccountManager localNumber];
if (localNumber) {
[whitelistedRecipientIds removeObject:localNumber];
} else {
OWSFailDebug(@"Missing localNumber");
}
NSSet<NSString *> *blockedRecipientIds = [NSSet setWithArray:self.blockingManager.blockedPhoneNumbers];
NSSet<NSData *> *blockedGroupIds = [NSSet setWithArray:self.blockingManager.blockedGroupIds];
// Find the users and groups which are both a) blocked b) may have our current profile key.
NSMutableSet<NSString *> *intersectingRecipientIds = [blockedRecipientIds mutableCopy];
[intersectingRecipientIds intersectSet:whitelistedRecipientIds];
NSMutableSet<NSData *> *intersectingGroupIds = [blockedGroupIds mutableCopy];
[intersectingGroupIds intersectSet:whitelistedGroupIds];
BOOL isProfileKeySharedWithBlocked = (intersectingRecipientIds.count > 0 || intersectingGroupIds.count > 0);
if (!isProfileKeySharedWithBlocked) {
// No need to rotate the profile key.
return success();
}
[self rotateProfileKeyWithIntersectingRecipientIds:intersectingRecipientIds
intersectingGroupIds:intersectingGroupIds
success:success
failure:failure];
});
}
- (void)rotateProfileKeyWithIntersectingRecipientIds:(NSSet<NSString *> *)intersectingRecipientIds
intersectingGroupIds:(NSSet<NSData *> *)intersectingGroupIds
success:(dispatch_block_t)success
failure:(ProfileManagerFailureBlock)failure {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// Rotate the profile key
OWSLogInfo(@"Rotating the profile key.");
// Make copies of the current local profile state.
OWSUserProfile *localUserProfile = self.localUserProfile;
NSString *_Nullable oldProfileName = localUserProfile.profileName;
NSString *_Nullable oldAvatarFileName = localUserProfile.avatarFileName;
NSData *_Nullable oldAvatarData = [self profileAvatarDataForRecipientId:self.tsAccountManager.localNumber];
// Rotate the stored profile key.
AnyPromise *promise = [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
[self.localUserProfile updateWithProfileKey:[OWSAES256Key generateRandomKey]
dbConnection:self.dbConnection
completion:^{
// The value doesn't matter, we just need any non-NSError value.
resolve(@(1));
}];
}];
// Try to re-upload our profile name, if any.
//
// This may fail.
promise = promise.then(^(id value) {
if (oldProfileName.length < 1) {
return [AnyPromise promiseWithValue:@(1)];
}
return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
[self updateServiceWithProfileName:oldProfileName
avatarUrl:localUserProfile.avatarUrlPath
success:^{
OWSLogInfo(@"Update to profile name succeeded.");
// The value doesn't matter, we just need any non-NSError value.
resolve(@(1));
}
failure:^(NSError *error) {
resolve(error);
}];
}];
});
// Try to re-upload our profile avatar, if any.
//
// This may fail.
promise = promise.then(^(id value) {
if (oldAvatarData.length < 1 || oldAvatarFileName.length < 1) {
return [AnyPromise promiseWithValue:@(1)];
}
return [AnyPromise promiseWithResolverBlock:^(PMKResolver resolve) {
[self uploadAvatarToService:oldAvatarData
success:^(NSString *_Nullable avatarUrlPath) {
OWSLogInfo(@"Update to profile avatar after profile key rotation succeeded.");
// The profile manager deletes the underlying file when updating a profile URL
// So we need to copy the underlying file to a new location.
NSString *oldPath = [OWSUserProfile profileAvatarFilepathWithFilename:oldAvatarFileName];
NSString *newAvatarFilename = [self generateAvatarFilename];
NSString *newPath = [OWSUserProfile profileAvatarFilepathWithFilename:newAvatarFilename];
NSError *error;
[NSFileManager.defaultManager copyItemAtPath:oldPath toPath:newPath error:&error];
OWSAssertDebug(!error);
[self.localUserProfile updateWithAvatarUrlPath:avatarUrlPath
avatarFileName:newAvatarFilename
dbConnection:self.dbConnection
completion:^{
// The value doesn't matter, we just need any
// non-NSError value.
resolve(@(1));
}];
}
failure:^(NSError *error) {
OWSLogInfo(@"Update to profile avatar after profile key rotation failed.");
resolve(error);
}];
}];
});
// Try to re-upload our profile avatar, if any.
//
// This may fail.
promise = promise.then(^(id value) {
// Remove blocked users and groups from profile whitelist.
//
// This will always succeed.
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
[transaction removeObjectsForKeys:intersectingRecipientIds.allObjects
inCollection:kOWSProfileManager_UserWhitelistCollection];
for (NSData *groupId in intersectingGroupIds) {
NSString *groupIdKey = [self groupKeyForGroupId:groupId];
[transaction removeObjectForKey:groupIdKey
inCollection:kOWSProfileManager_GroupWhitelistCollection];
}
}];
return @(1);
});
// Update account attributes.
//
// This may fail.
promise = promise.then(^(id value) {
return [self.tsAccountManager updateAccountAttributes];
});
promise = promise.then(^(id value) {
[[NSNotificationCenter defaultCenter] postNotificationNameAsync:kNSNotificationName_ProfileKeyDidChange
object:nil
userInfo:nil];
success();
});
promise = promise.catch(^(NSError *error) {
if ([error isKindOfClass:[NSError class]]) {
failure(error);
} else {
failure(OWSErrorMakeAssertionError(@"Profile key rotation failure missing error."));
}
});
[promise retainUntilComplete];
});
}
- (void)regenerateLocalProfile
{
OWSUserProfile *userProfile = self.localUserProfile;
[userProfile clearWithProfileKey:[OWSAES256Key generateRandomKey] dbConnection:self.dbConnection completion:nil];
[[self.tsAccountManager updateAccountAttributes] retainUntilComplete];
}
#pragma mark - Other Users' Profiles
- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId avatarURL:(nullable NSString *)avatarURL
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
OWSAES256Key *_Nullable profileKey = [OWSAES256Key keyWithData:profileKeyData];
if (profileKey == nil) {
OWSFailDebug(@"Failed to make profile key for key data");
return;
}
OWSUserProfile *userProfile = [OWSUserProfile getOrBuildUserProfileForRecipientId:recipientId dbConnection:self.dbConnection];
OWSAssertDebug(userProfile);
if (userProfile.profileKey && [userProfile.profileKey.keyData isEqual:profileKey.keyData]) {
// Ignore redundant update.
return;
}
[userProfile clearWithProfileKey:profileKey
dbConnection:self.dbConnection
completion:^{
dispatch_async(dispatch_get_main_queue(), ^{
[userProfile updateWithAvatarUrlPath:avatarURL avatarFileName:nil dbConnection:self.dbConnection completion:^{
[self downloadAvatarForUserProfile:userProfile];
}];
});
}];
});
}
- (void)setProfileKeyData:(NSData *)profileKeyData forRecipientId:(NSString *)recipientId
{
[self setProfileKeyData:profileKeyData forRecipientId:recipientId avatarURL:nil];
}
- (nullable NSData *)profileKeyDataForRecipientId:(NSString *)recipientId
{
return [self profileKeyForRecipientId:recipientId].keyData;
}
- (nullable OWSAES256Key *)profileKeyForRecipientId:(NSString *)recipientId
{
OWSAssertDebug(recipientId.length > 0);
// For "local reads", use the local user profile.
OWSUserProfile *userProfile = ([self.tsAccountManager.localNumber isEqualToString:recipientId]
? self.localUserProfile
: [OWSUserProfile getOrBuildUserProfileForRecipientId:recipientId dbConnection:self.dbConnection]);
OWSAssertDebug(userProfile);
return userProfile.profileKey;
}
- (nullable NSString *)profileNameForRecipientWithID:(NSString *)recipientID transaction:(YapDatabaseReadWriteTransaction *)transaction
{
OWSAssertDebug(recipientID.length > 0);
// For "local reads", use the local user profile.
OWSUserProfile *userProfile = [self.tsAccountManager.localNumber isEqualToString:recipientID]
? [self getLocalUserProfileWithTransaction:transaction]
: [OWSUserProfile getOrBuildUserProfileForRecipientId:recipientID transaction:transaction];
return userProfile.profileName;
}
- (nullable UIImage *)profileAvatarForRecipientId:(NSString *)recipientId
{
OWSAssertDebug(recipientId.length > 0);
// For "local reads", use the local user profile.
OWSUserProfile *userProfile = ([self.tsAccountManager.localNumber isEqualToString:recipientId]
? self.localUserProfile
: [OWSUserProfile getOrBuildUserProfileForRecipientId:recipientId dbConnection:self.dbConnection]);
if (userProfile.avatarFileName.length > 0) {
return [self loadProfileAvatarWithFilename:userProfile.avatarFileName];
}
if (userProfile.avatarUrlPath.length > 0) {
[self downloadAvatarForUserProfile:userProfile];
}
return nil;
}
- (nullable NSData *)profileAvatarDataForRecipientId:(NSString *)recipientId
{
OWSAssertDebug(recipientId.length > 0);
// For "local reads", use the local user profile.
OWSUserProfile *userProfile = ([self.tsAccountManager.localNumber isEqualToString:recipientId]
? self.localUserProfile
: [OWSUserProfile getOrBuildUserProfileForRecipientId:recipientId dbConnection:self.dbConnection]);
if (userProfile.avatarFileName.length > 0) {
return [self loadProfileDataWithFilename:userProfile.avatarFileName];
}
return nil;
}
- (NSString *)generateAvatarFilename
{
return [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"jpg"];
}
- (void)downloadAvatarForUserProfile:(OWSUserProfile *)userProfile
{
OWSAssertDebug(userProfile);
__block OWSBackgroundTask *backgroundTask = [OWSBackgroundTask backgroundTaskWithLabelStr:__PRETTY_FUNCTION__];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
if (userProfile.avatarUrlPath.length < 1) {
OWSLogDebug(@"Skipping downloading avatar for %@ because url is not set", userProfile.recipientId);
return;
}
NSString *_Nullable avatarUrlPathAtStart = userProfile.avatarUrlPath;
if (userProfile.profileKey.keyData.length < 1 || userProfile.avatarUrlPath.length < 1) {
return;
}
OWSAES256Key *profileKeyAtStart = userProfile.profileKey;
NSString *fileName = [self generateAvatarFilename];
NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:fileName];
@synchronized(self.currentAvatarDownloads)
{
if ([self.currentAvatarDownloads containsObject:userProfile.recipientId]) {
// Download already in flight; ignore.
return;
}
[self.currentAvatarDownloads addObject:userProfile.recipientId];
}
OWSLogVerbose(@"downloading profile avatar: %@", userProfile.uniqueId);
NSString *profilePictureURL = userProfile.avatarUrlPath;
NSString *file = [profilePictureURL lastPathComponent];
BOOL useOldServer = [profilePictureURL containsString:SNFileServerAPIV2.oldServer];
AnyPromise *promise = [SNFileServerAPIV2 download:file useOldServer:useOldServer];
[promise.then(^(NSData *data) {
@synchronized(self.currentAvatarDownloads)
{
[self.currentAvatarDownloads removeObject:userProfile.recipientId];
}
NSData *_Nullable encryptedData = data;
NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKeyAtStart];
UIImage *_Nullable image = nil;
if (decryptedData) {
BOOL success = [decryptedData writeToFile:filePath atomically:YES];
if (success) {
image = [UIImage imageWithContentsOfFile:filePath];
}
}
OWSUserProfile *latestUserProfile = [OWSUserProfile getOrBuildUserProfileForRecipientId:userProfile.recipientId dbConnection:self.dbConnection];
if (latestUserProfile.profileKey.keyData.length < 1
|| ![latestUserProfile.profileKey isEqual:userProfile.profileKey]) {
OWSLogWarn(@"Ignoring avatar download for obsolete user profile.");
} else if (![avatarUrlPathAtStart isEqualToString:latestUserProfile.avatarUrlPath]) {
OWSLogInfo(@"avatar url has changed during download");
if (latestUserProfile.avatarUrlPath.length > 0) {
[self downloadAvatarForUserProfile:latestUserProfile];
}
} else if (!encryptedData) {
OWSLogError(@"avatar encrypted data for %@ could not be read.", userProfile.recipientId);
} else if (!decryptedData) {
OWSLogError(@"avatar data for %@ could not be decrypted.", userProfile.recipientId);
} else if (!image) {
OWSLogError(@"avatar image for %@ could not be loaded.", userProfile.recipientId);
} else {
[latestUserProfile updateWithAvatarFileName:fileName dbConnection:self.dbConnection completion:nil];
[self updateProfileAvatarCache:image filename:fileName];
}
OWSAssertDebug(backgroundTask);
backgroundTask = nil;
}) retainUntilComplete];
});
}
- (void)updateProfileForRecipientId:(NSString *)recipientId
profileNameEncrypted:(nullable NSData *)profileNameEncrypted
avatarUrlPath:(nullable NSString *)avatarUrlPath
{
OWSAssertDebug(recipientId.length > 0);
OWSLogDebug(@"update profile for: %@ name: %@ avatar: %@", recipientId, profileNameEncrypted, avatarUrlPath);
// Ensure decryption, etc. off main thread.
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
OWSUserProfile *userProfile =
[OWSUserProfile getOrBuildUserProfileForRecipientId:recipientId dbConnection:self.dbConnection];
NSString *_Nullable localNumber = self.tsAccountManager.localNumber;
// If we're updating the profile that corresponds to our local number,
// make sure we're using the latest key.
if (localNumber && [localNumber isEqualToString:recipientId]) {
[userProfile updateWithProfileKey:self.localUserProfile.profileKey
dbConnection:self.dbConnection
completion:nil];
}
if (!userProfile.profileKey) {
return;
}
NSString *_Nullable profileName =
[self decryptProfileNameData:profileNameEncrypted profileKey:userProfile.profileKey];
[userProfile updateWithProfileName:profileName
avatarUrlPath:avatarUrlPath
dbConnection:self.dbConnection
completion:nil];
// If we're updating the profile that corresponds to our local number,
// update the local profile as well.
if (localNumber && [localNumber isEqualToString:recipientId]) {
OWSUserProfile *localUserProfile = self.localUserProfile;
OWSAssertDebug(localUserProfile);
[localUserProfile updateWithProfileName:profileName
avatarUrlPath:avatarUrlPath
dbConnection:self.dbConnection
completion:nil];
}
// Whenever we change avatarUrlPath, OWSUserProfile clears avatarFileName.
// So if avatarUrlPath is set and avatarFileName is not set, we should to
// download this avatar. downloadAvatarForUserProfile will de-bounce
// downloads.
if (userProfile.avatarUrlPath.length > 0 && userProfile.avatarFileName.length < 1) {
[self downloadAvatarForUserProfile:userProfile];
}
});
}
- (void)updateProfileForContactWithID:(NSString *)contactID displayName:(NSString *)displayName with:(YapDatabaseReadWriteTransaction *)transaction
{
OWSUserProfile *userProfile = [OWSUserProfile getOrBuildUserProfileForRecipientId:contactID transaction:transaction];
[userProfile updateWithProfileName:displayName avatarUrlPath:userProfile.avatarUrlPath avatarFileName:userProfile.avatarFileName transaction:transaction completion:nil];
}
- (void)ensureProfileCachedForContactWithID:(NSString *)contactID with:(YapDatabaseReadWriteTransaction *)transaction
{
OWSUserProfile *userProfile = [OWSUserProfile getOrBuildUserProfileForRecipientId:contactID transaction:transaction];
[userProfile saveWithTransaction:transaction];
}
- (BOOL)isNullableDataEqual:(NSData *_Nullable)left toData:(NSData *_Nullable)right
{
if (left == nil && right == nil) {
return YES;
} else if (left == nil || right == nil) {
return YES;
} else {
return [left isEqual:right];
}
}
- (BOOL)isNullableStringEqual:(NSString *_Nullable)left toString:(NSString *_Nullable)right
{
if (left == nil && right == nil) {
return YES;
} else if (left == nil || right == nil) {
return YES;
} else {
return [left isEqualToString:right];
}
}
#pragma mark - Profile Encryption
- (nullable NSData *)encryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey
{
OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength);
if (!encryptedData) {
return nil;
}
return [Cryptography encryptAESGCMWithProfileData:encryptedData key:profileKey];
}
- (nullable NSData *)decryptProfileData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey
{
OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength);
if (!encryptedData) {
return nil;
}
return [Cryptography decryptAESGCMWithProfileData:encryptedData key:profileKey];
}
- (nullable NSString *)decryptProfileNameData:(nullable NSData *)encryptedData profileKey:(OWSAES256Key *)profileKey
{
OWSAssertDebug(profileKey.keyData.length == kAES256_KeyByteLength);
NSData *_Nullable decryptedData = [self decryptProfileData:encryptedData profileKey:profileKey];
if (decryptedData.length < 1) {
return nil;
}
// Unpad profile name.
NSUInteger unpaddedLength = 0;
const char *bytes = decryptedData.bytes;
// Work through the bytes until we encounter our first
// padding byte (our padding scheme is NULL bytes)
for (NSUInteger i = 0; i < decryptedData.length; i++) {
if (bytes[i] == 0x00) {
break;
}
unpaddedLength = i + 1;
}
NSData *unpaddedData = [decryptedData subdataWithRange:NSMakeRange(0, unpaddedLength)];
return [[NSString alloc] initWithData:unpaddedData encoding:NSUTF8StringEncoding];
}
- (nullable NSData *)encryptProfileData:(nullable NSData *)data
{
return [self encryptProfileData:data profileKey:self.localProfileKey];
}
- (BOOL)isProfileNameTooLong:(nullable NSString *)profileName
{
OWSAssertIsOnMainThread();
NSData *nameData = [profileName dataUsingEncoding:NSUTF8StringEncoding];
return nameData.length > kOWSProfileManager_NameDataLength;
}
- (nullable NSData *)encryptProfileNameWithUnpaddedName:(NSString *)name
{
NSData *nameData = [name dataUsingEncoding:NSUTF8StringEncoding];
if (nameData.length > kOWSProfileManager_NameDataLength) {
OWSFailDebug(@"name data is too long with length:%lu", (unsigned long)nameData.length);
return nil;
}
NSUInteger paddingByteCount = kOWSProfileManager_NameDataLength - nameData.length;
NSMutableData *paddedNameData = [nameData mutableCopy];
// Since we want all encrypted profile names to be the same length on the server, we use `increaseLengthBy`
// to pad out any remaining length with 0 bytes.
[paddedNameData increaseLengthBy:paddingByteCount];
OWSAssertDebug(paddedNameData.length == kOWSProfileManager_NameDataLength);
return [self encryptProfileData:[paddedNameData copy] profileKey:self.localProfileKey];
}
#pragma mark - Avatar Disk Cache
- (nullable NSData *)loadProfileDataWithFilename:(NSString *)filename
{
if (filename.length <= 0) { return nil; };
NSString *filePath = [OWSUserProfile profileAvatarFilepathWithFilename:filename];
return [NSData dataWithContentsOfFile:filePath];
}
- (nullable UIImage *)loadProfileAvatarWithFilename:(NSString *)filename
{
if (filename.length == 0) {
return nil;
}
UIImage *_Nullable image = nil;
@synchronized(self.profileAvatarImageCache)
{
image = [self.profileAvatarImageCache objectForKey:filename];
}
if (image) {
return image;
}
NSData *data = [self loadProfileDataWithFilename:filename];
if (![data ows_isValidImage]) {
return nil;
}
image = [UIImage imageWithData:data];
[self updateProfileAvatarCache:image filename:filename];
return image;
}
- (void)updateProfileAvatarCache:(nullable UIImage *)image filename:(NSString *)filename
{
if (filename.length <= 0) { return; };
@synchronized(self.profileAvatarImageCache)
{
if (image) {
[self.profileAvatarImageCache setObject:image forKey:filename];
} else {
[self.profileAvatarImageCache removeObjectForKey:filename];
}
}
}
#pragma mark - Notifications
- (void)blockListDidChange:(NSNotification *)notification {
OWSAssertIsOnMainThread();
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[self rotateLocalProfileKeyIfNecessary];
}];
}
@end
NS_ASSUME_NONNULL_END