mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			738 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			Objective-C
		
	
			
		
		
	
	
			738 lines
		
	
	
		
			30 KiB
		
	
	
	
		
			Objective-C
		
	
| //
 | |
| //  Copyright (c) 2019 Open Whisper Systems. All rights reserved.
 | |
| //
 | |
| 
 | |
| #import "OWSOrphanDataCleaner.h"
 | |
| #import "DateUtil.h"
 | |
| #import <SignalCoreKit/NSDate+OWS.h>
 | |
| #import <SignalUtilitiesKit/OWSProfileManager.h>
 | |
| #import <SessionMessagingKit/OWSUserProfile.h>
 | |
| #import <SessionMessagingKit/AppReadiness.h>
 | |
| #import <SignalUtilitiesKit/AppVersion.h>
 | |
| #import <SessionUtilitiesKit/SessionUtilitiesKit.h>
 | |
| #import <SessionUtilitiesKit/OWSFileSystem.h>
 | |
| #import <SessionMessagingKit/OWSPrimaryStorage.h>
 | |
| #import <SessionMessagingKit/TSAttachmentStream.h>
 | |
| #import <SessionMessagingKit/TSInteraction.h>
 | |
| #import <SessionMessagingKit/TSMessage.h>
 | |
| #import <SessionMessagingKit/TSQuotedMessage.h>
 | |
| #import <SessionMessagingKit/TSThread.h>
 | |
| #import <SessionMessagingKit/YapDatabaseTransaction+OWS.h>
 | |
| #import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
 | |
| #import <YapDatabase/YapDatabase.h>
 | |
| 
 | |
| NS_ASSUME_NONNULL_BEGIN
 | |
| 
 | |
| // LOG_ALL_FILE_PATHS can be used to determine if there are other kinds of files
 | |
| // that we're not cleaning up.
 | |
| //#define LOG_ALL_FILE_PATHS
 | |
| 
 | |
| #define ENABLE_ORPHAN_DATA_CLEANER
 | |
| 
 | |
| NSString *const OWSOrphanDataCleaner_Collection = @"OWSOrphanDataCleaner_Collection";
 | |
| NSString *const OWSOrphanDataCleaner_LastCleaningVersionKey = @"OWSOrphanDataCleaner_LastCleaningVersionKey";
 | |
| NSString *const OWSOrphanDataCleaner_LastCleaningDateKey = @"OWSOrphanDataCleaner_LastCleaningDateKey";
 | |
| 
 | |
| @interface OWSOrphanData : NSObject
 | |
| 
 | |
| @property (nonatomic) NSSet<NSString *> *interactionIds;
 | |
| @property (nonatomic) NSSet<NSString *> *attachmentIds;
 | |
| @property (nonatomic) NSSet<NSString *> *filePaths;
 | |
| 
 | |
| @end
 | |
| 
 | |
| #pragma mark -
 | |
| 
 | |
| @implementation OWSOrphanData
 | |
| 
 | |
| @end
 | |
| 
 | |
| #pragma mark -
 | |
| 
 | |
| typedef void (^OrphanDataBlock)(OWSOrphanData *);
 | |
| 
 | |
| @implementation OWSOrphanDataCleaner
 | |
| 
 | |
| // Unlike CurrentAppContext().isMainAppAndActive, this method can be safely
 | |
| // invoked off the main thread.
 | |
| + (BOOL)isMainAppAndActive
 | |
| {
 | |
|     return CurrentAppContext().reportedApplicationState == UIApplicationStateActive;
 | |
| }
 | |
| 
 | |
| + (void)printPaths:(NSArray<NSString *> *)paths label:(NSString *)label
 | |
| {
 | |
|     for (NSString *path in [paths sortedArrayUsingSelector:@selector(compare:)]) {
 | |
|         OWSLogDebug(@"%@: %@", label, path);
 | |
|     }
 | |
| }
 | |
| 
 | |
| + (long long)fileSizeOfFilePath:(NSString *)filePath
 | |
| {
 | |
|     NSError *error;
 | |
|     NSNumber *fileSize = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:&error][NSFileSize];
 | |
|     if (error) {
 | |
|         if ([error.domain isEqualToString:NSCocoaErrorDomain] && error.code == 260) {
 | |
|             OWSLogWarn(@"can't find size of missing file.");
 | |
|             OWSLogDebug(@"can't find size of missing file: %@", filePath);
 | |
|         } else {
 | |
|             OWSFailDebug(@"attributesOfItemAtPath: %@ error: %@", filePath, error);
 | |
|         }
 | |
|         return 0;
 | |
|     }
 | |
|     return fileSize.longLongValue;
 | |
| }
 | |
| 
 | |
| + (nullable NSNumber *)fileSizeOfFilePathsSafe:(NSArray<NSString *> *)filePaths
 | |
| {
 | |
|     long long result = 0;
 | |
|     for (NSString *filePath in filePaths) {
 | |
|         if (!self.isMainAppAndActive) {
 | |
|             return nil;
 | |
|         }
 | |
|         result += [self fileSizeOfFilePath:filePath];
 | |
|     }
 | |
|     return @(result);
 | |
| }
 | |
| 
 | |
| + (nullable NSSet<NSString *> *)filePathsInDirectorySafe:(NSString *)dirPath
 | |
| {
 | |
|     NSMutableSet *filePaths = [NSMutableSet new];
 | |
|     if (![[NSFileManager defaultManager] fileExistsAtPath:dirPath]) {
 | |
|         return filePaths;
 | |
|     }
 | |
|     NSError *error;
 | |
|     NSArray<NSString *> *fileNames = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dirPath error:&error];
 | |
|     if (error) {
 | |
|         OWSFailDebug(@"contentsOfDirectoryAtPath error: %@", error);
 | |
|         return [NSSet new];
 | |
|     }
 | |
|     for (NSString *fileName in fileNames) {
 | |
|         if (!self.isMainAppAndActive) {
 | |
|             return nil;
 | |
|         }
 | |
|         NSString *filePath = [dirPath stringByAppendingPathComponent:fileName];
 | |
|         BOOL isDirectory;
 | |
|         [[NSFileManager defaultManager] fileExistsAtPath:filePath isDirectory:&isDirectory];
 | |
|         if (isDirectory) {
 | |
|             NSSet<NSString *> *_Nullable dirPaths = [self filePathsInDirectorySafe:filePath];
 | |
|             if (!dirPaths) {
 | |
|                 return nil;
 | |
|             }
 | |
|             [filePaths unionSet:dirPaths];
 | |
|         } else {
 | |
|             [filePaths addObject:filePath];
 | |
|         }
 | |
|     }
 | |
|     return filePaths;
 | |
| }
 | |
| 
 | |
| // This method finds (but does not delete):
 | |
| //
 | |
| // * Orphan TSInteractions (with no thread).
 | |
| // * Orphan TSAttachments (with no message).
 | |
| // * Orphan attachment files (with no corresponding TSAttachment).
 | |
| // * Orphan profile avatars.
 | |
| // * Temporary files (all).
 | |
| //
 | |
| // It also finds (we don't clean these up).
 | |
| //
 | |
| // * Missing attachment files (cannot be cleaned up).
 | |
| //   These are attachments which have no file on disk.  They should be extremely rare -
 | |
| //   the only cases I have seen are probably due to debugging.
 | |
| //   They can't be cleaned up - we don't want to delete the TSAttachmentStream or
 | |
| //   its corresponding message.  Better that the broken message shows up in the
 | |
| //   conversation view.
 | |
| + (void)findOrphanDataWithRetries:(NSInteger)remainingRetries
 | |
|                databaseConnection:(YapDatabaseConnection *)databaseConnection
 | |
|                           success:(OrphanDataBlock)success
 | |
|                           failure:(dispatch_block_t)failure
 | |
| {
 | |
|     OWSAssertDebug(databaseConnection);
 | |
| 
 | |
|     if (remainingRetries < 1) {
 | |
|         OWSLogInfo(@"Aborting orphan data search.");
 | |
|         dispatch_async(self.workQueue, ^{
 | |
|             failure();
 | |
|         });
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Wait until the app is active...
 | |
|     [CurrentAppContext() runNowOrWhenMainAppIsActive:^{
 | |
|         // ...but perform the work off the main thread.
 | |
|         dispatch_async(self.workQueue, ^{
 | |
|             OWSOrphanData *_Nullable orphanData = [self findOrphanDataSync:databaseConnection];
 | |
|             if (orphanData) {
 | |
|                 success(orphanData);
 | |
|             } else {
 | |
|                 [self findOrphanDataWithRetries:remainingRetries - 1
 | |
|                              databaseConnection:databaseConnection
 | |
|                                         success:success
 | |
|                                         failure:failure];
 | |
|             }
 | |
|         });
 | |
|     }];
 | |
| }
 | |
| 
 | |
| // Returns nil on failure, usually indicating that the search
 | |
| // aborted due to the app resigning active.  This method is extremely careful to
 | |
| // abort if the app resigns active, in order to avoid 0xdead10cc crashes.
 | |
| + (nullable OWSOrphanData *)findOrphanDataSync:(YapDatabaseConnection *)databaseConnection
 | |
| {
 | |
|     OWSAssertDebug(databaseConnection);
 | |
| 
 | |
|     __block BOOL shouldAbort = NO;
 | |
| 
 | |
| #ifdef LOG_ALL_FILE_PATHS
 | |
|     {
 | |
|         NSString *documentDirPath = [OWSFileSystem appDocumentDirectoryPath];
 | |
|         NSArray<NSString *> *_Nullable allDocumentFilePaths =
 | |
|             [self filePathsInDirectorySafe:documentDirPath].allObjects;
 | |
|         allDocumentFilePaths = [allDocumentFilePaths sortedArrayUsingSelector:@selector(compare:)];
 | |
|         NSString *attachmentsFolder = [TSAttachmentStream attachmentsFolder];
 | |
|         for (NSString *filePath in allDocumentFilePaths) {
 | |
|             if ([filePath hasPrefix:attachmentsFolder]) {
 | |
|                 continue;
 | |
|             }
 | |
|             OWSLogVerbose(@"non-attachment file: %@", filePath);
 | |
|         }
 | |
|     }
 | |
|     {
 | |
|         NSString *documentDirPath = [OWSFileSystem appSharedDataDirectoryPath];
 | |
|         NSArray<NSString *> *_Nullable allDocumentFilePaths =
 | |
|             [self filePathsInDirectorySafe:documentDirPath].allObjects;
 | |
|         allDocumentFilePaths = [allDocumentFilePaths sortedArrayUsingSelector:@selector(compare:)];
 | |
|         NSString *attachmentsFolder = [TSAttachmentStream attachmentsFolder];
 | |
|         for (NSString *filePath in allDocumentFilePaths) {
 | |
|             if ([filePath hasPrefix:attachmentsFolder]) {
 | |
|                 continue;
 | |
|             }
 | |
|             OWSLogVerbose(@"non-attachment file: %@", filePath);
 | |
|         }
 | |
|     }
 | |
| #endif
 | |
| 
 | |
|     // We treat _all_ temp files as orphan files.  This is safe
 | |
|     // because temp files only need to be retained for the
 | |
|     // a single launch of the app.  Since our "date threshold"
 | |
|     // for deletion is relative to the current launch time,
 | |
|     // all temp files currently in use should be safe.
 | |
|     NSArray<NSString *> *_Nullable tempFilePaths = [self getTempFilePaths];
 | |
|     if (!tempFilePaths || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
| #ifdef LOG_ALL_FILE_PATHS
 | |
|     {
 | |
|         NSDateFormatter *dateFormatter = [NSDateFormatter new];
 | |
|         [dateFormatter setDateStyle:NSDateFormatterLongStyle];
 | |
|         [dateFormatter setTimeStyle:NSDateFormatterLongStyle];
 | |
| 
 | |
|         tempFilePaths = [tempFilePaths sortedArrayUsingSelector:@selector(compare:)];
 | |
|         for (NSString *filePath in tempFilePaths) {
 | |
|             NSError *error;
 | |
|             NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:&error];
 | |
|             if (!attributes || error) {
 | |
|                 OWSLogDebug(@"Could not get attributes of file at: %@", filePath);
 | |
|                 OWSFailDebug(@"Could not get attributes of file");
 | |
|                 continue;
 | |
|             }
 | |
|             OWSLogVerbose(
 | |
|                 @"temp file: %@, %@", filePath, [dateFormatter stringFromDate:attributes.fileModificationDate]);
 | |
|         }
 | |
|     }
 | |
| #endif
 | |
| 
 | |
|     NSString *legacyAttachmentsDirPath = TSAttachmentStream.legacyAttachmentsDirPath;
 | |
|     NSString *sharedDataAttachmentsDirPath = TSAttachmentStream.sharedDataAttachmentsDirPath;
 | |
|     NSSet<NSString *> *_Nullable legacyAttachmentFilePaths = [self filePathsInDirectorySafe:legacyAttachmentsDirPath];
 | |
|     if (!legacyAttachmentFilePaths || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
|     NSSet<NSString *> *_Nullable sharedDataAttachmentFilePaths =
 | |
|         [self filePathsInDirectorySafe:sharedDataAttachmentsDirPath];
 | |
|     if (!sharedDataAttachmentFilePaths || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     NSString *legacyProfileAvatarsDirPath = OWSUserProfile.legacyProfileAvatarsDirPath;
 | |
|     NSString *sharedDataProfileAvatarsDirPath = OWSUserProfile.sharedDataProfileAvatarsDirPath;
 | |
|     NSSet<NSString *> *_Nullable legacyProfileAvatarsFilePaths =
 | |
|         [self filePathsInDirectorySafe:legacyProfileAvatarsDirPath];
 | |
|     if (!legacyProfileAvatarsFilePaths || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
|     NSSet<NSString *> *_Nullable sharedDataProfileAvatarFilePaths =
 | |
|         [self filePathsInDirectorySafe:sharedDataProfileAvatarsDirPath];
 | |
|     if (!sharedDataProfileAvatarFilePaths || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     NSMutableSet<NSString *> *allOnDiskFilePaths = [NSMutableSet new];
 | |
|     [allOnDiskFilePaths unionSet:legacyAttachmentFilePaths];
 | |
|     [allOnDiskFilePaths unionSet:sharedDataAttachmentFilePaths];
 | |
|     [allOnDiskFilePaths unionSet:legacyProfileAvatarsFilePaths];
 | |
|     [allOnDiskFilePaths unionSet:sharedDataProfileAvatarFilePaths];
 | |
|     [allOnDiskFilePaths addObjectsFromArray:tempFilePaths];
 | |
| 
 | |
|     NSSet<NSString *> *profileAvatarFilePaths = [OWSUserProfile allProfileAvatarFilePaths];
 | |
| 
 | |
|     if (!self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     NSNumber *_Nullable totalFileSize = [self fileSizeOfFilePathsSafe:allOnDiskFilePaths.allObjects];
 | |
| 
 | |
|     if (!totalFileSize || !self.isMainAppAndActive) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     NSUInteger fileCount = allOnDiskFilePaths.count;
 | |
| 
 | |
|     // Attachments
 | |
|     __block int attachmentStreamCount = 0;
 | |
|     NSMutableSet<NSString *> *allAttachmentFilePaths = [NSMutableSet new];
 | |
|     NSMutableSet<NSString *> *allAttachmentIds = [NSMutableSet new];
 | |
|     // Threads
 | |
|     __block NSSet *threadIds;
 | |
|     // Messages
 | |
|     NSMutableSet<NSString *> *orphanInteractionIds = [NSMutableSet new];
 | |
|     NSMutableSet<NSString *> *allMessageAttachmentIds = [NSMutableSet new];
 | |
|     [databaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
 | |
|         [transaction
 | |
|             enumerateKeysAndObjectsInCollection:TSAttachmentStream.collection
 | |
|                                      usingBlock:^(NSString *key, TSAttachment *attachment, BOOL *stop) {
 | |
|                                          if (!self.isMainAppAndActive) {
 | |
|                                              shouldAbort = YES;
 | |
|                                              *stop = YES;
 | |
|                                              return;
 | |
|                                          }
 | |
|                                          if (![attachment isKindOfClass:[TSAttachmentStream class]]) {
 | |
|                                              return;
 | |
|                                          }
 | |
|                                          [allAttachmentIds addObject:attachment.uniqueId];
 | |
| 
 | |
|                                          TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment;
 | |
|                                          attachmentStreamCount++;
 | |
|                                          NSString *_Nullable filePath = [attachmentStream originalFilePath];
 | |
|                                          if (filePath) {
 | |
|                                              [allAttachmentFilePaths addObject:filePath];
 | |
|                                          } else {
 | |
|                                              OWSFailDebug(@"attachment has no file path.");
 | |
|                                          }
 | |
| 
 | |
|                                          [allAttachmentFilePaths
 | |
|                                              addObjectsFromArray:attachmentStream.allThumbnailPaths];
 | |
|                                      }];
 | |
| 
 | |
|         if (shouldAbort) {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         threadIds = [NSSet setWithArray:[transaction allKeysInCollection:TSThread.collection]];
 | |
| 
 | |
|         [transaction
 | |
|             enumerateKeysAndObjectsInCollection:TSMessage.collection
 | |
|                                      usingBlock:^(NSString *key, TSInteraction *interaction, BOOL *stop) {
 | |
|                                          if (!self.isMainAppAndActive) {
 | |
|                                              shouldAbort = YES;
 | |
|                                              *stop = YES;
 | |
|                                              return;
 | |
|                                          }
 | |
|                                          if (interaction.uniqueThreadId.length < 1
 | |
|                                              || ![threadIds containsObject:interaction.uniqueThreadId]) {
 | |
|                                              [orphanInteractionIds addObject:interaction.uniqueId];
 | |
|                                          }
 | |
| 
 | |
|                                          if (![interaction isKindOfClass:[TSMessage class]]) {
 | |
|                                              return;
 | |
|                                          }
 | |
| 
 | |
|                                          TSMessage *message = (TSMessage *)interaction;
 | |
|                                          [allMessageAttachmentIds addObjectsFromArray:message.allAttachmentIds];
 | |
|                                      }];
 | |
|     }];
 | |
|     if (shouldAbort) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     OWSLogDebug(@"fileCount: %zu", fileCount);
 | |
|     OWSLogDebug(@"totalFileSize: %lld", totalFileSize.longLongValue);
 | |
|     OWSLogDebug(@"attachmentStreams: %d", attachmentStreamCount);
 | |
|     OWSLogDebug(@"attachmentStreams with file paths: %zu", allAttachmentFilePaths.count);
 | |
| 
 | |
|     NSMutableSet<NSString *> *orphanFilePaths = [allOnDiskFilePaths mutableCopy];
 | |
|     [orphanFilePaths minusSet:allAttachmentFilePaths];
 | |
|     [orphanFilePaths minusSet:profileAvatarFilePaths];
 | |
|     NSMutableSet<NSString *> *missingAttachmentFilePaths = [allAttachmentFilePaths mutableCopy];
 | |
|     [missingAttachmentFilePaths minusSet:allOnDiskFilePaths];
 | |
| 
 | |
|     OWSLogDebug(@"orphan file paths: %zu", orphanFilePaths.count);
 | |
|     OWSLogDebug(@"missing attachment file paths: %zu", missingAttachmentFilePaths.count);
 | |
| 
 | |
|     [self printPaths:orphanFilePaths.allObjects label:@"orphan file paths"];
 | |
|     [self printPaths:missingAttachmentFilePaths.allObjects label:@"missing attachment file paths"];
 | |
| 
 | |
|     OWSLogDebug(@"attachmentIds: %zu", allAttachmentIds.count);
 | |
|     OWSLogDebug(@"allMessageAttachmentIds: %zu", allMessageAttachmentIds.count);
 | |
| 
 | |
|     NSMutableSet<NSString *> *orphanAttachmentIds = [allAttachmentIds mutableCopy];
 | |
|     [orphanAttachmentIds minusSet:allMessageAttachmentIds];
 | |
|     NSMutableSet<NSString *> *missingAttachmentIds = [allMessageAttachmentIds mutableCopy];
 | |
|     [missingAttachmentIds minusSet:allAttachmentIds];
 | |
| 
 | |
|     OWSLogDebug(@"orphan attachmentIds: %zu", orphanAttachmentIds.count);
 | |
|     OWSLogDebug(@"missing attachmentIds: %zu", missingAttachmentIds.count);
 | |
|     OWSLogDebug(@"orphan interactions: %zu", orphanInteractionIds.count);
 | |
| 
 | |
|     OWSOrphanData *result = [OWSOrphanData new];
 | |
|     result.interactionIds = [orphanInteractionIds copy];
 | |
|     result.attachmentIds = [orphanAttachmentIds copy];
 | |
|     result.filePaths = [orphanFilePaths copy];
 | |
|     return result;
 | |
| }
 | |
| 
 | |
| + (BOOL)shouldAuditOnLaunch:(YapDatabaseConnection *)databaseConnection {
 | |
|     OWSAssertIsOnMainThread();
 | |
| 
 | |
| #ifndef ENABLE_ORPHAN_DATA_CLEANER
 | |
|     return NO;
 | |
| #endif
 | |
| 
 | |
|     __block NSString *_Nullable lastCleaningVersion;
 | |
|     __block NSDate *_Nullable lastCleaningDate;
 | |
|     [databaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
 | |
|         lastCleaningVersion = [transaction stringForKey:OWSOrphanDataCleaner_LastCleaningVersionKey
 | |
|                                            inCollection:OWSOrphanDataCleaner_Collection];
 | |
|         lastCleaningDate = [transaction dateForKey:OWSOrphanDataCleaner_LastCleaningDateKey
 | |
|                                       inCollection:OWSOrphanDataCleaner_Collection];
 | |
|     }];
 | |
| 
 | |
|     // Clean up once per app version.
 | |
|     NSString *currentAppVersion = AppVersion.sharedInstance.currentAppVersion;
 | |
|     if (!lastCleaningVersion || ![lastCleaningVersion isEqualToString:currentAppVersion]) {
 | |
|         OWSLogVerbose(@"Performing orphan data cleanup; new version: %@.", currentAppVersion);
 | |
|         return YES;
 | |
|     }
 | |
| 
 | |
|     // Clean up once per N days.
 | |
|     if (lastCleaningDate) {
 | |
| #ifdef DEBUG
 | |
|         BOOL shouldAudit = [DateUtil dateIsOlderThanToday:lastCleaningDate];
 | |
| #else
 | |
|         BOOL shouldAudit = [DateUtil dateIsOlderThanOneWeek:lastCleaningDate];
 | |
| #endif
 | |
| 
 | |
|         if (shouldAudit) {
 | |
|             OWSLogVerbose(@"Performing orphan data cleanup; time has passed.");
 | |
|         }
 | |
|         return shouldAudit;
 | |
|     }
 | |
| 
 | |
|     // Has never audited before.
 | |
|     return NO;
 | |
| }
 | |
| 
 | |
| + (void)auditOnLaunchIfNecessary {
 | |
|     OWSAssertIsOnMainThread();
 | |
| 
 | |
|     OWSPrimaryStorage *primaryStorage = [OWSPrimaryStorage sharedManager];
 | |
|     YapDatabaseConnection *databaseConnection = [primaryStorage newDatabaseConnection];
 | |
| 
 | |
|     if (![self shouldAuditOnLaunch:databaseConnection]) {
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // If we want to be cautious, we can disable orphan deletion using
 | |
|     // flag - the cleanup will just be a dry run with logging.
 | |
|     BOOL shouldRemoveOrphans = YES;
 | |
|     [self auditAndCleanup:shouldRemoveOrphans databaseConnection:databaseConnection completion:nil];
 | |
| }
 | |
| 
 | |
| + (void)auditAndCleanup:(BOOL)shouldRemoveOrphans
 | |
| {
 | |
|     [self auditAndCleanup:shouldRemoveOrphans
 | |
|                completion:^ {
 | |
|                }];
 | |
| }
 | |
| 
 | |
| + (void)auditAndCleanup:(BOOL)shouldRemoveOrphans completion:(dispatch_block_t)completion
 | |
| {
 | |
|     OWSPrimaryStorage *primaryStorage = [OWSPrimaryStorage sharedManager];
 | |
|     YapDatabaseConnection *databaseConnection = [primaryStorage newDatabaseConnection];
 | |
| 
 | |
|     [self auditAndCleanup:shouldRemoveOrphans databaseConnection:databaseConnection completion:completion];
 | |
| }
 | |
| 
 | |
| // We use the lowest priority possible.
 | |
| + (dispatch_queue_t)workQueue
 | |
| {
 | |
|     return dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0);
 | |
| }
 | |
| 
 | |
| + (void)auditAndCleanup:(BOOL)shouldRemoveOrphans
 | |
|      databaseConnection:(YapDatabaseConnection *)databaseConnection
 | |
|              completion:(nullable dispatch_block_t)completion
 | |
| {
 | |
|     OWSAssertIsOnMainThread();
 | |
|     OWSAssertDebug(databaseConnection);
 | |
| 
 | |
|     if (!AppReadiness.isAppReady) {
 | |
|         OWSFailDebug(@"can't audit orphan data until app is ready.");
 | |
|         return;
 | |
|     }
 | |
|     if (!CurrentAppContext().isMainApp) {
 | |
|         OWSFailDebug(@"can't audit orphan data in app extensions.");
 | |
|         return;
 | |
|     }
 | |
|     if (CurrentAppContext().isRunningTests) {
 | |
|         OWSLogVerbose(@"Ignoring audit orphan data in tests.");
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Orphan cleanup has two risks:
 | |
|     //
 | |
|     // * As a long-running process that involves access to the
 | |
|     //   shared data container, it could cause 0xdead10cc.
 | |
|     // * It could accidentally delete data still in use,
 | |
|     //   e.g. a profile avatar which has been saved to disk
 | |
|     //   but whose OWSUserProfile hasn't been saved yet.
 | |
|     //
 | |
|     // To prevent 0xdead10cc, the cleaner continually checks
 | |
|     // whether the app has resigned active.  If so, it aborts.
 | |
|     // Each phase (search, re-search, processing) retries N times,
 | |
|     // then gives up until the next app launch.
 | |
|     //
 | |
|     // To prevent accidental data deletion, we take the following
 | |
|     // measures:
 | |
|     //
 | |
|     // * Only cleanup data of the following types (which should
 | |
|     //   include all relevant app data): profile avatar,
 | |
|     //   attachment, temporary files (including temporary
 | |
|     //   attachments).
 | |
|     // * We don't delete any data created more recently than N seconds
 | |
|     //   _before_ when the app launched.  This prevents any stray data
 | |
|     //   currently in use by the app from being accidentally cleaned
 | |
|     //   up.
 | |
|     const NSInteger kMaxRetries = 3;
 | |
|     [self findOrphanDataWithRetries:kMaxRetries
 | |
|         databaseConnection:databaseConnection
 | |
|         success:^(OWSOrphanData *orphanData) {
 | |
|             [self processOrphans:orphanData
 | |
|                 remainingRetries:kMaxRetries
 | |
|                 databaseConnection:databaseConnection
 | |
|                 shouldRemoveOrphans:shouldRemoveOrphans
 | |
|                 success:^{
 | |
|                     OWSLogInfo(@"Completed orphan data cleanup.");
 | |
| 
 | |
|                     [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
 | |
|                         [transaction setObject:AppVersion.sharedInstance.currentAppVersion
 | |
|                                         forKey:OWSOrphanDataCleaner_LastCleaningVersionKey
 | |
|                                   inCollection:OWSOrphanDataCleaner_Collection];
 | |
|                         [transaction setDate:[NSDate new]
 | |
|                                       forKey:OWSOrphanDataCleaner_LastCleaningDateKey
 | |
|                                 inCollection:OWSOrphanDataCleaner_Collection];
 | |
|                     }];
 | |
| 
 | |
|                     if (completion) {
 | |
|                         completion();
 | |
|                     }
 | |
|                 }
 | |
|                 failure:^{
 | |
|                     OWSLogInfo(@"Aborting orphan data cleanup.");
 | |
|                     if (completion) {
 | |
|                         completion();
 | |
|                     }
 | |
|                 }];
 | |
|         }
 | |
|         failure:^{
 | |
|             OWSLogInfo(@"Aborting orphan data cleanup.");
 | |
|             if (completion) {
 | |
|                 completion();
 | |
|             }
 | |
|         }];
 | |
| }
 | |
| 
 | |
| // Returns NO on failure, usually indicating that orphan processing
 | |
| // aborted due to the app resigning active.  This method is extremely careful to
 | |
| // abort if the app resigns active, in order to avoid 0xdead10cc crashes.
 | |
| + (void)processOrphans:(OWSOrphanData *)orphanData
 | |
|        remainingRetries:(NSInteger)remainingRetries
 | |
|      databaseConnection:(YapDatabaseConnection *)databaseConnection
 | |
|     shouldRemoveOrphans:(BOOL)shouldRemoveOrphans
 | |
|                 success:(dispatch_block_t)success
 | |
|                 failure:(dispatch_block_t)failure
 | |
| {
 | |
|     OWSAssertDebug(databaseConnection);
 | |
|     OWSAssertDebug(orphanData);
 | |
| 
 | |
|     if (remainingRetries < 1) {
 | |
|         OWSLogInfo(@"Aborting orphan data audit.");
 | |
|         dispatch_async(self.workQueue, ^{
 | |
|             failure();
 | |
|         });
 | |
|         return;
 | |
|     }
 | |
| 
 | |
|     // Wait until the app is active...
 | |
|     [CurrentAppContext() runNowOrWhenMainAppIsActive:^{
 | |
|         // ...but perform the work off the main thread.
 | |
|         dispatch_async(self.workQueue, ^{
 | |
|             if ([self processOrphansSync:orphanData
 | |
|                       databaseConnection:databaseConnection
 | |
|                      shouldRemoveOrphans:shouldRemoveOrphans]) {
 | |
|                 success();
 | |
|                 return;
 | |
|             } else {
 | |
|                 [self processOrphans:orphanData
 | |
|                        remainingRetries:remainingRetries - 1
 | |
|                      databaseConnection:databaseConnection
 | |
|                     shouldRemoveOrphans:shouldRemoveOrphans
 | |
|                                 success:success
 | |
|                                 failure:failure];
 | |
|             }
 | |
|         });
 | |
|     }];
 | |
| }
 | |
| 
 | |
| // Returns NO on failure, usually indicating that orphan processing
 | |
| // aborted due to the app resigning active.  This method is extremely careful to
 | |
| // abort if the app resigns active, in order to avoid 0xdead10cc crashes.
 | |
| + (BOOL)processOrphansSync:(OWSOrphanData *)orphanData
 | |
|         databaseConnection:(YapDatabaseConnection *)databaseConnection
 | |
|        shouldRemoveOrphans:(BOOL)shouldRemoveOrphans
 | |
| {
 | |
|     OWSAssertDebug(databaseConnection);
 | |
|     OWSAssertDebug(orphanData);
 | |
| 
 | |
|     __block BOOL shouldAbort = NO;
 | |
| 
 | |
|     // We need to avoid cleaning up new files that are still in the process of
 | |
|     // being created/written, so we don't clean up anything recent.
 | |
|     const NSTimeInterval kMinimumOrphanAgeSeconds = CurrentAppContext().isRunningTests ? 0.f : 15 * kMinuteInterval;
 | |
|     NSDate *appLaunchTime = CurrentAppContext().appLaunchTime;
 | |
|     NSTimeInterval thresholdTimestamp = appLaunchTime.timeIntervalSince1970 - kMinimumOrphanAgeSeconds;
 | |
|     NSDate *thresholdDate = [NSDate dateWithTimeIntervalSince1970:thresholdTimestamp];
 | |
|     [LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *transaction) {
 | |
|         NSUInteger interactionsRemoved = 0;
 | |
|         for (NSString *interactionId in orphanData.interactionIds) {
 | |
|             if (!self.isMainAppAndActive) {
 | |
|                 shouldAbort = YES;
 | |
|                 return;
 | |
|             }
 | |
|             TSInteraction *_Nullable interaction =
 | |
|                 [TSInteraction fetchObjectWithUniqueID:interactionId transaction:transaction];
 | |
|             if (!interaction) {
 | |
|                 // This could just be a race condition, but it should be very unlikely.
 | |
|                 OWSLogWarn(@"Could not load interaction: %@", interactionId);
 | |
|                 continue;
 | |
|             }
 | |
|             // Don't delete interactions which were created in the last N minutes.
 | |
|             NSDate *creationDate = [NSDate ows_dateWithMillisecondsSince1970:interaction.timestamp];
 | |
|             if ([creationDate isAfterDate:thresholdDate]) {
 | |
|                 OWSLogInfo(@"Skipping orphan interaction due to age: %f", fabs(creationDate.timeIntervalSinceNow));
 | |
|                 continue;
 | |
|             }
 | |
|             OWSLogInfo(@"Removing orphan message: %@", interaction.uniqueId);
 | |
|             interactionsRemoved++;
 | |
|             if (!shouldRemoveOrphans) {
 | |
|                 continue;
 | |
|             }
 | |
|             [interaction removeWithTransaction:transaction];
 | |
|         }
 | |
|         OWSLogInfo(@"Deleted orphan interactions: %zu", interactionsRemoved);
 | |
| 
 | |
|         NSUInteger attachmentsRemoved = 0;
 | |
|         for (NSString *attachmentId in orphanData.attachmentIds) {
 | |
|             if (!self.isMainAppAndActive) {
 | |
|                 shouldAbort = YES;
 | |
|                 return;
 | |
|             }
 | |
|             TSAttachment *_Nullable attachment =
 | |
|                 [TSAttachment fetchObjectWithUniqueID:attachmentId transaction:transaction];
 | |
|             if (!attachment) {
 | |
|                 // This can happen on launch since we sync contacts/groups, especially if you have a lot of attachments
 | |
|                 // to churn through, it's likely it's been deleted since starting this job.
 | |
|                 OWSLogWarn(@"Could not load attachment: %@", attachmentId);
 | |
|                 continue;
 | |
|             }
 | |
|             if (![attachment isKindOfClass:[TSAttachmentStream class]]) {
 | |
|                 continue;
 | |
|             }
 | |
|             TSAttachmentStream *attachmentStream = (TSAttachmentStream *)attachment;
 | |
|             // Don't delete attachments which were created in the last N minutes.
 | |
|             NSDate *creationDate = attachmentStream.creationTimestamp;
 | |
|             if ([creationDate isAfterDate:thresholdDate]) {
 | |
|                 OWSLogInfo(@"Skipping orphan attachment due to age: %f", fabs(creationDate.timeIntervalSinceNow));
 | |
|                 continue;
 | |
|             }
 | |
|             OWSLogInfo(@"Removing orphan attachmentStream: %@", attachmentStream.uniqueId);
 | |
|             attachmentsRemoved++;
 | |
|             if (!shouldRemoveOrphans) {
 | |
|                 continue;
 | |
|             }
 | |
|             [attachmentStream removeWithTransaction:transaction];
 | |
|         }
 | |
|         OWSLogInfo(@"Deleted orphan attachments: %zu", attachmentsRemoved);
 | |
|     }];
 | |
| 
 | |
|     if (shouldAbort) {
 | |
|         return nil;
 | |
|     }
 | |
| 
 | |
|     NSUInteger filesRemoved = 0;
 | |
|     NSArray<NSString *> *filePaths = [orphanData.filePaths.allObjects sortedArrayUsingSelector:@selector(compare:)];
 | |
|     for (NSString *filePath in filePaths) {
 | |
|         if (!self.isMainAppAndActive) {
 | |
|             return nil;
 | |
|         }
 | |
| 
 | |
|         NSError *error;
 | |
|         NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:filePath error:&error];
 | |
|         if (!attributes || error) {
 | |
|             // This is fine; the file may have been deleted since we found it.
 | |
|             OWSLogWarn(@"Could not get attributes of file at: %@", filePath);
 | |
|             continue;
 | |
|         }
 | |
|         // Don't delete files which were created in the last N minutes.
 | |
|         NSDate *creationDate = attributes.fileModificationDate;
 | |
|         if ([creationDate isAfterDate:thresholdDate]) {
 | |
|             OWSLogInfo(@"Skipping file due to age: %f", fabs([creationDate timeIntervalSinceNow]));
 | |
|             continue;
 | |
|         }
 | |
|         OWSLogInfo(@"Deleting file: %@", filePath);
 | |
|         filesRemoved++;
 | |
|         if (!shouldRemoveOrphans) {
 | |
|             continue;
 | |
|         }
 | |
|         [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error];
 | |
|         if (error) {
 | |
|             OWSLogDebug(@"Could not remove orphan file at: %@", filePath);
 | |
|             OWSFailDebug(@"Could not remove orphan file");
 | |
|         }
 | |
|     }
 | |
|     OWSLogInfo(@"Deleted orphan files: %zu", filesRemoved);
 | |
| 
 | |
|     return YES;
 | |
| }
 | |
| 
 | |
| + (nullable NSArray<NSString *> *)getTempFilePaths
 | |
| {
 | |
|     NSString *dir1 = OWSTemporaryDirectory();
 | |
|     NSArray<NSString *> *_Nullable paths1 = [[self filePathsInDirectorySafe:dir1].allObjects mutableCopy];
 | |
| 
 | |
|     NSString *dir2 = OWSTemporaryDirectoryAccessibleAfterFirstAuth();
 | |
|     NSArray<NSString *> *_Nullable paths2 = [[self filePathsInDirectorySafe:dir2].allObjects mutableCopy];
 | |
| 
 | |
|     if (paths1 && paths2) {
 | |
|         return [paths1 arrayByAddingObjectsFromArray:paths2];
 | |
|     } else {
 | |
|         return nil;
 | |
|     }
 | |
| }
 | |
| 
 | |
| @end
 | |
| 
 | |
| NS_ASSUME_NONNULL_END
 |