// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // #import "OWSOrphanDataCleaner.h" #import "DateUtil.h" #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import 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 NSString *const OWSOrphanDataCleaner_LastCleaningVersionKey = @"OWSOrphanDataCleaner_LastCleaningVersionKey"; NSString *const OWSOrphanDataCleaner_LastCleaningDateKey = @"OWSOrphanDataCleaner_LastCleaningDateKey"; @interface OWSOrphanData : NSObject @property (nonatomic) NSSet *interactionIds; @property (nonatomic) NSSet *attachmentIds; @property (nonatomic) NSSet *filePaths; @property (nonatomic) NSSet *reactionIds; @property (nonatomic) NSSet *mentionIds; @end #pragma mark - @implementation OWSOrphanData @end #pragma mark - typedef void (^OrphanDataBlock)(OWSOrphanData *); @implementation OWSOrphanDataCleaner - (instancetype)init { self = [super init]; if (!self) { return self; } OWSSingletonAssert(); AppReadinessRunNowOrWhenAppDidBecomeReadyAsync(^{ [OWSOrphanDataCleaner auditOnLaunchIfNecessary]; }); return self; } + (SDSKeyValueStore *)keyValueStore { static SDSKeyValueStore *keyValueStore = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ NSString *const OWSOrphanDataCleaner_Collection = @"OWSOrphanDataCleaner_Collection"; keyValueStore = [[SDSKeyValueStore alloc] initWithCollection:OWSOrphanDataCleaner_Collection]; }); return keyValueStore; } // Unlike CurrentAppContext().isMainAppAndActive, this method can be safely // invoked off the main thread. + (BOOL)isMainAppAndActive { return CurrentAppContext().reportedApplicationState == UIApplicationStateActive; } + (void)printPaths:(NSArray *)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 *)filePaths { long long result = 0; for (NSString *filePath in filePaths) { if (!self.isMainAppAndActive) { return nil; } result += [self fileSizeOfFilePath:filePath]; } return @(result); } + (nullable NSSet *)filePathsInDirectorySafe:(NSString *)dirPath { NSMutableSet *filePaths = [NSMutableSet new]; if (![[NSFileManager defaultManager] fileExistsAtPath:dirPath]) { return filePaths; } NSError *error; NSArray *fileNames = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dirPath error:&error]; if (error) { if ([error.domain isEqualToString:NSPOSIXErrorDomain] && error.code == ENOENT) { // Races may cause files to be removed while we crawl the directory contents. OWSLogWarn(@"Error: %@", error); } else { OWSFailDebug(@"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 *_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 success:(OrphanDataBlock)success failure:(dispatch_block_t)failure { 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]; if (orphanData) { success(orphanData); } else { [self findOrphanDataWithRetries:remainingRetries - 1 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 { __block BOOL shouldAbort = NO; #ifdef LOG_ALL_FILE_PATHS { NSString *documentDirPath = [OWSFileSystem appDocumentDirectoryPath]; NSArray *_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 *_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 *_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 *_Nullable legacyAttachmentFilePaths = [self filePathsInDirectorySafe:legacyAttachmentsDirPath]; if (!legacyAttachmentFilePaths || !self.isMainAppAndActive) { return nil; } NSSet *_Nullable sharedDataAttachmentFilePaths = [self filePathsInDirectorySafe:sharedDataAttachmentsDirPath]; if (!sharedDataAttachmentFilePaths || !self.isMainAppAndActive) { return nil; } NSString *legacyProfileAvatarsDirPath = OWSUserProfile.legacyProfileAvatarsDirPath; NSString *sharedDataProfileAvatarsDirPath = OWSUserProfile.sharedDataProfileAvatarsDirPath; NSSet *_Nullable legacyProfileAvatarsFilePaths = [self filePathsInDirectorySafe:legacyProfileAvatarsDirPath]; if (!legacyProfileAvatarsFilePaths || !self.isMainAppAndActive) { return nil; } NSSet *_Nullable sharedDataProfileAvatarFilePaths = [self filePathsInDirectorySafe:sharedDataProfileAvatarsDirPath]; if (!sharedDataProfileAvatarFilePaths || !self.isMainAppAndActive) { return nil; } NSString *stickersDirPath = StickerManager.cacheDirUrl.path; NSSet *_Nullable allStickerFilePaths = [self filePathsInDirectorySafe:stickersDirPath]; if (!allStickerFilePaths || !self.isMainAppAndActive) { return nil; } NSString *voiceMessageDirPath = VoiceMessageModel.draftVoiceMessageDirectory.path; NSSet *_Nullable allVoiceMessageFilePaths = [self filePathsInDirectorySafe:voiceMessageDirPath]; if (!allVoiceMessageFilePaths || !self.isMainAppAndActive) { return nil; } NSMutableSet *allOnDiskFilePaths = [NSMutableSet new]; [allOnDiskFilePaths unionSet:legacyAttachmentFilePaths]; [allOnDiskFilePaths unionSet:sharedDataAttachmentFilePaths]; [allOnDiskFilePaths unionSet:legacyProfileAvatarsFilePaths]; [allOnDiskFilePaths unionSet:sharedDataProfileAvatarFilePaths]; [allOnDiskFilePaths unionSet:allStickerFilePaths]; [allOnDiskFilePaths unionSet:allVoiceMessageFilePaths]; [allOnDiskFilePaths addObjectsFromArray:tempFilePaths]; // This should be redundant, but this will future-proof us against // ever accidentally removing the GRDB databases during // orphan clean up. NSString *grdbPrimaryDirectoryPath = [GRDBDatabaseStorageAdapter databaseDirUrlWithBaseDir:SDSDatabaseStorage.baseDir directoryMode:DirectoryModePrimary] .path; NSString *grdbHotswapDirectoryPath = [GRDBDatabaseStorageAdapter databaseDirUrlWithBaseDir:SDSDatabaseStorage.baseDir directoryMode:DirectoryModeHotswap] .path; NSMutableSet *databaseFilePaths = [NSMutableSet new]; for (NSString *filePath in allOnDiskFilePaths) { if ([filePath hasPrefix:grdbPrimaryDirectoryPath]) { OWSLogInfo(@"Protecting database file: %@", filePath); [databaseFilePaths addObject:filePath]; } else if ([filePath hasPrefix:grdbHotswapDirectoryPath]) { OWSLogInfo(@"Protecting database hotswap file: %@", filePath); [databaseFilePaths addObject:filePath]; } } [allOnDiskFilePaths minusSet:databaseFilePaths]; OWSLogVerbose(@"grdbDirectoryPath: %@ (%d)", grdbPrimaryDirectoryPath, [OWSFileSystem fileOrFolderExistsAtPath:grdbPrimaryDirectoryPath]); OWSLogVerbose(@"databaseFilePaths: %lu", (unsigned long)databaseFilePaths.count); OWSLogVerbose(@"allOnDiskFilePaths: %lu", (unsigned long)allOnDiskFilePaths.count); __block NSSet *voiceMessageDraftFilePaths; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { voiceMessageDraftFilePaths = [VoiceMessageModel allDraftFilePathsWithTransaction:transaction]; }]; __block NSSet *profileAvatarFilePaths; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { profileAvatarFilePaths = [OWSProfileManager allProfileAvatarFilePathsWithTransaction:transaction]; }]; 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 *allAttachmentFilePaths = [NSMutableSet new]; NSMutableSet *allAttachmentIds = [NSMutableSet new]; // Reactions NSMutableSet *allReactionIds = [NSMutableSet new]; // Mentions NSMutableSet *allMentionIds = [NSMutableSet new]; // Threads __block NSSet *threadIds; // Messages NSMutableSet *orphanInteractionIds = [NSMutableSet new]; NSMutableSet *allMessageAttachmentIds = [NSMutableSet new]; NSMutableSet *allMessageReactionIds = [NSMutableSet new]; NSMutableSet *allMessageMentionIds = [NSMutableSet new]; // Stickers NSMutableSet *activeStickerFilePaths = [NSMutableSet new]; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { [TSAttachmentStream anyEnumerateWithTransaction:transaction batched:YES block:^(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.allSecondaryFilePaths]; }]; if (shouldAbort) { return; } threadIds = [NSSet setWithArray:[TSThread anyAllUniqueIdsWithTransaction:transaction]]; NSMutableSet *allInteractionIds = [NSMutableSet new]; [TSInteraction anyEnumerateWithTransaction:transaction batched:YES block:^(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]; } [allInteractionIds addObject:interaction.uniqueId]; if (![interaction isKindOfClass:[TSMessage class]]) { return; } TSMessage *message = (TSMessage *)interaction; [allMessageAttachmentIds addObjectsFromArray:message.allAttachmentIds]; }]; if (shouldAbort) { return; } [OWSReaction anyEnumerateWithTransaction:transaction batched:YES block:^(OWSReaction *reaction, BOOL *stop) { if (!self.isMainAppAndActive) { shouldAbort = YES; *stop = YES; return; } if (![reaction isKindOfClass:[OWSReaction class]]) { return; } [allReactionIds addObject:reaction.uniqueId]; if ([allInteractionIds containsObject:reaction.uniqueMessageId]) { [allMessageReactionIds addObject:reaction.uniqueId]; } }]; if (shouldAbort) { return; } [TSMention anyEnumerateWithTransaction:transaction batched:YES block:^(TSMention *mention, BOOL *stop) { if (!self.isMainAppAndActive) { shouldAbort = YES; *stop = YES; return; } if (![mention isKindOfClass:[TSMention class]]) { return; } [allMentionIds addObject:mention.uniqueId]; if ([allInteractionIds containsObject:mention.uniqueMessageId]) { [allMessageMentionIds addObject:mention.uniqueId]; } }]; if (shouldAbort) { return; } [MessageSenderJobQueue enumerateEnqueuedInteractionsWithTransaction:transaction block:^(TSInteraction *interaction, BOOL *stop) { if (!self.isMainAppAndActive) { shouldAbort = YES; *stop = YES; return; } if (![interaction isKindOfClass:[TSMessage class]]) { return; } TSMessage *message = (TSMessage *)interaction; [allMessageAttachmentIds addObjectsFromArray:message.allAttachmentIds]; }]; [[JobRecordFinderObjC new] enumerateJobRecordsWithLabel:OWSBroadcastMediaMessageJobRecord.defaultLabel transaction:transaction block:^(SSKJobRecord *jobRecord, BOOL *stopPtr) { if (![jobRecord isKindOfClass:OWSBroadcastMediaMessageJobRecord.class]) { OWSFailDebug(@"unexpected jobRecord: %@", jobRecord); return; } OWSBroadcastMediaMessageJobRecord *broadcastJobRecord = (OWSBroadcastMediaMessageJobRecord *)jobRecord; [allMessageAttachmentIds addObjectsFromArray:broadcastJobRecord.attachmentIdMap.allKeys]; }]; [[JobRecordFinderObjC new] enumerateJobRecordsWithLabel:OWSIncomingGroupSyncJobRecord.defaultLabel transaction:transaction block:^(SSKJobRecord *jobRecord, BOOL *stopPtr) { if (![jobRecord isKindOfClass:OWSIncomingGroupSyncJobRecord.class]) { OWSFailDebug(@"unexpected jobRecord: %@", jobRecord); return; } OWSIncomingGroupSyncJobRecord *groupSyncJobRecord = (OWSIncomingGroupSyncJobRecord *)jobRecord; [allMessageAttachmentIds addObject:groupSyncJobRecord.attachmentId]; }]; [[JobRecordFinderObjC new] enumerateJobRecordsWithLabel:OWSIncomingContactSyncJobRecord.defaultLabel transaction:transaction block:^(SSKJobRecord *jobRecord, BOOL *stopPtr) { if (![jobRecord isKindOfClass:OWSIncomingContactSyncJobRecord.class]) { OWSFailDebug(@"unexpected jobRecord: %@", jobRecord); return; } OWSIncomingContactSyncJobRecord *contactSyncJobRecord = (OWSIncomingContactSyncJobRecord *)jobRecord; [allMessageAttachmentIds addObject:contactSyncJobRecord.attachmentId]; }]; if (shouldAbort) { return; } [activeStickerFilePaths addObjectsFromArray:[StickerManager filepathsForAllInstalledStickersWithTransaction:transaction]]; }]; 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 *orphanFilePaths = [allOnDiskFilePaths mutableCopy]; [orphanFilePaths minusSet:allAttachmentFilePaths]; [orphanFilePaths minusSet:profileAvatarFilePaths]; [orphanFilePaths minusSet:voiceMessageDraftFilePaths]; [orphanFilePaths minusSet:activeStickerFilePaths]; NSMutableSet *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 *orphanAttachmentIds = [allAttachmentIds mutableCopy]; [orphanAttachmentIds minusSet:allMessageAttachmentIds]; NSMutableSet *missingAttachmentIds = [allMessageAttachmentIds mutableCopy]; [missingAttachmentIds minusSet:allAttachmentIds]; OWSLogDebug(@"orphan attachmentIds: %zu", orphanAttachmentIds.count); OWSLogDebug(@"missing attachmentIds: %zu", missingAttachmentIds.count); OWSLogDebug(@"orphan interactions: %zu", orphanInteractionIds.count); NSMutableSet *orphanReactionIds = [allReactionIds mutableCopy]; [orphanReactionIds minusSet:allMessageReactionIds]; NSMutableSet *missingReactionIds = [allMessageReactionIds mutableCopy]; [missingReactionIds minusSet:allReactionIds]; OWSLogDebug(@"orphan reactionIds: %zu", orphanReactionIds.count); OWSLogDebug(@"missing reactionIds: %zu", missingReactionIds.count); NSMutableSet *orphanMentionIds = [allMentionIds mutableCopy]; [orphanMentionIds minusSet:allMessageMentionIds]; NSMutableSet *missingMentionIds = [allMessageMentionIds mutableCopy]; [missingMentionIds minusSet:allMentionIds]; OWSLogDebug(@"orphan mentionIds: %zu", orphanMentionIds.count); OWSLogDebug(@"missing mentionIds: %zu", missingMentionIds.count); OWSOrphanData *result = [OWSOrphanData new]; result.interactionIds = [orphanInteractionIds copy]; result.attachmentIds = [orphanAttachmentIds copy]; result.filePaths = [orphanFilePaths copy]; result.reactionIds = [orphanReactionIds copy]; result.mentionIds = [orphanMentionIds copy]; return result; } + (BOOL)shouldAuditOnLaunch { OWSAssertIsOnMainThread(); if (!CurrentAppContext().isMainApp || CurrentAppContext().isRunningTests || !TSAccountManager.shared.isRegistered) { return NO; } if (!SSKFeatureFlags.useOrphanDataCleaner) { return NO; } __block NSString *_Nullable lastCleaningVersion; __block NSDate *_Nullable lastCleaningDate; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { lastCleaningVersion = [self.keyValueStore getString:OWSOrphanDataCleaner_LastCleaningVersionKey transaction:transaction]; lastCleaningDate = [self.keyValueStore getDate:OWSOrphanDataCleaner_LastCleaningDateKey transaction:transaction]; }]; // Clean up once per app version. NSString *currentAppVersion = AppVersion.shared.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(); if (![self shouldAuditOnLaunch]) { 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 completion:nil]; } + (void)auditAndCleanup:(BOOL)shouldRemoveOrphans { [self auditAndCleanup:shouldRemoveOrphans 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 completion:(nullable dispatch_block_t)completion { OWSAssertIsOnMainThread(); 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; } if (SSKDebugFlags.suppressBackgroundActivity) { // Don't clean up. return; } OWSLogInfo(@""); // 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 success:^(OWSOrphanData *orphanData) { [self processOrphans:orphanData remainingRetries:kMaxRetries shouldRemoveOrphans:shouldRemoveOrphans success:^{ OWSLogInfo(@"Completed orphan data cleanup."); DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { [self.keyValueStore setString:AppVersion.shared.currentAppVersion key:OWSOrphanDataCleaner_LastCleaningVersionKey transaction:transaction]; [self.keyValueStore setDate:[NSDate new] key:OWSOrphanDataCleaner_LastCleaningDateKey transaction:transaction]; }); 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 shouldRemoveOrphans:(BOOL)shouldRemoveOrphans success:(dispatch_block_t)success failure:(dispatch_block_t)failure { 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 shouldRemoveOrphans:shouldRemoveOrphans]) { success(); return; } else { [self processOrphans:orphanData remainingRetries:remainingRetries - 1 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 shouldRemoveOrphans:(BOOL)shouldRemoveOrphans { OWSAssertDebug(orphanData); if (!self.isMainAppAndActive) { return NO; } __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]; DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { NSUInteger interactionsRemoved = 0; for (NSString *interactionId in orphanData.interactionIds) { if (!self.isMainAppAndActive) { shouldAbort = YES; return; } TSInteraction *_Nullable interaction = [TSInteraction anyFetchWithUniqueId: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 anyRemoveWithTransaction: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 anyFetchWithUniqueId: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 anyRemoveWithTransaction:transaction]; } OWSLogInfo(@"Deleted orphan attachments: %zu", attachmentsRemoved); NSUInteger reactionsRemoved = 0; for (NSString *reactionId in orphanData.reactionIds) { if (!self.isMainAppAndActive) { shouldAbort = YES; return; } OWSReaction *_Nullable reaction = [OWSReaction anyFetchWithUniqueId:reactionId transaction:transaction]; if (!reaction) { // This could just be a race condition, but it should be very unlikely. OWSLogWarn(@"Could not load reaction: %@", reactionId); continue; } // Don't delete reactions which were created in the last N minutes. NSDate *creationDate = [NSDate ows_dateWithMillisecondsSince1970:reaction.sentAtTimestamp]; if ([creationDate isAfterDate:thresholdDate]) { OWSLogInfo(@"Skipping orphan reaction due to age: %f", fabs(creationDate.timeIntervalSinceNow)); continue; } OWSLogInfo(@"Removing orphan reaction: %@", reaction.uniqueId); reactionsRemoved++; if (!shouldRemoveOrphans) { continue; } [reaction anyRemoveWithTransaction:transaction]; } OWSLogInfo(@"Deleted orphan reactions: %zu", reactionsRemoved); NSUInteger mentionsRemoved = 0; for (NSString *mentionId in orphanData.mentionIds) { if (!self.isMainAppAndActive) { shouldAbort = YES; return; } TSMention *_Nullable mention = [TSMention anyFetchWithUniqueId:mentionId transaction:transaction]; if (!mention) { // This could just be a race condition, but it should be very unlikely. OWSLogWarn(@"Could not load mention: %@", mentionId); continue; } // Don't delete mentions which were created in the last N minutes. NSDate *creationDate = mention.creationTimestamp; if ([creationDate isAfterDate:thresholdDate]) { OWSLogInfo(@"Skipping orphan mention due to age: %f", fabs(creationDate.timeIntervalSinceNow)); continue; } OWSLogInfo(@"Removing orphan mention: %@", mention.uniqueId); mentionsRemoved++; if (!shouldRemoveOrphans) { continue; } [mention anyRemoveWithTransaction:transaction]; } OWSLogInfo(@"Deleted orphan mentions: %zu", mentionsRemoved); }); if (shouldAbort) { return NO; } NSUInteger filesRemoved = 0; NSArray *filePaths = [orphanData.filePaths.allObjects sortedArrayUsingSelector:@selector(compare:)]; for (NSString *filePath in filePaths) { if (!self.isMainAppAndActive) { return NO; } 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; } if (![OWSFileSystem fileOrFolderExistsAtPath:filePath]) { // Already removed. continue; } if (![OWSFileSystem deleteFile:filePath ignoreIfMissing:YES]) { OWSLogDebug(@"Could not remove orphan file at: %@", filePath); OWSFailDebug(@"Could not remove orphan file"); } } OWSLogInfo(@"Deleted orphan files: %zu", filesRemoved); return YES; } + (nullable NSArray *)getTempFilePaths { NSString *dir1 = OWSTemporaryDirectory(); NSArray *_Nullable paths1 = [[self filePathsInDirectorySafe:dir1].allObjects mutableCopy]; NSString *dir2 = OWSTemporaryDirectoryAccessibleAfterFirstAuth(); NSArray *_Nullable paths2 = [[self filePathsInDirectorySafe:dir2].allObjects mutableCopy]; if (paths1 && paths2) { return [paths1 arrayByAddingObjectsFromArray:paths2]; } else { return nil; } } @end NS_ASSUME_NONNULL_END