// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // #import "OWSOrphanDataCleaner.h" #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import NS_ASSUME_NONNULL_BEGIN NSString *const OWSOrphanDataCleaner_Collection = @"OWSOrphanDataCleaner_Collection"; NSString *const OWSOrphanDataCleaner_LastCleaningVersionKey = @"OWSOrphanDataCleaner_LastCleaningVersionKey"; @interface OWSOrphanData : NSObject @property (nonatomic) NSSet *interactionIds; @property (nonatomic) NSSet *attachmentIds; @property (nonatomic) NSSet *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 *)paths label:(NSString *)label { for (NSString *path in [paths sortedArrayUsingSelector:@selector(compare:)]) { DDLogDebug(@"%@ %@: %@", self.logTag, 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) { DDLogWarn(@"%@ can't find size of missing file: %@", self.logTag, filePath); } else { OWSProdLogAndFail(@"%@ attributesOfItemAtPath: %@ error: %@", self.logTag, 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) { OWSProdLogAndFail(@"%@ contentsOfDirectoryAtPath error: %@", self.logTag, 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 databaseConnection:(YapDatabaseConnection *)databaseConnection success:(OrphanDataBlock)success failure:(dispatch_block_t)failure { OWSAssert(databaseConnection); if (remainingRetries < 1) { DDLogInfo(@"%@ Aborting orphan data search.", self.logTag); 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 { OWSAssert(databaseConnection); __block BOOL shouldAbort = NO; // 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 #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; } DDLogVerbose(@"%@ non-attachment file: %@", self.logTag, 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; } DDLogVerbose(@"%@ non-attachment file: %@", self.logTag, 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. NSString *temporaryDirectory = NSTemporaryDirectory(); NSArray *_Nullable tempFilePaths = [self filePathsInDirectorySafe:temporaryDirectory].allObjects; 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) { OWSProdLogAndFail(@"%@ Could not get attributes of file at: %@", self.logTag, filePath); continue; } DDLogVerbose(@"%@ temp file: %@, %@", self.logTag, 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; } NSMutableSet *allOnDiskFilePaths = [NSMutableSet new]; [allOnDiskFilePaths unionSet:legacyAttachmentFilePaths]; [allOnDiskFilePaths unionSet:sharedDataAttachmentFilePaths]; [allOnDiskFilePaths unionSet:legacyProfileAvatarsFilePaths]; [allOnDiskFilePaths unionSet:sharedDataProfileAvatarFilePaths]; [allOnDiskFilePaths addObjectsFromArray:tempFilePaths]; NSSet *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 *allAttachmentFilePaths = [NSMutableSet new]; NSMutableSet *allAttachmentIds = [NSMutableSet new]; // Threads __block NSSet *threadIds; // Messages NSMutableSet *orphanInteractionIds = [NSMutableSet new]; NSMutableSet *messageAttachmentIds = [NSMutableSet new]; NSMutableSet *quotedReplyThumbnailAttachmentIds = [NSMutableSet new]; NSMutableSet *contactShareAvatarAttachmentIds = [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 filePath]; if (filePath) { [allAttachmentFilePaths addObject:filePath]; } else { OWSProdLogAndFail(@"%@ attachment has no file path.", self.logTag); } NSString *_Nullable thumbnailPath = [attachmentStream thumbnailPath]; if (thumbnailPath.length > 0) { [allAttachmentFilePaths addObject:thumbnailPath]; } }]; 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; if (message.attachmentIds.count > 0) { [messageAttachmentIds addObjectsFromArray:message.attachmentIds]; } TSQuotedMessage *_Nullable quotedMessage = message.quotedMessage; if (quotedMessage) { [quotedReplyThumbnailAttachmentIds addObjectsFromArray:quotedMessage.thumbnailAttachmentStreamIds]; } OWSContact *_Nullable contactShare = message.contactShare; if (contactShare && contactShare.avatarAttachmentId) { [contactShareAvatarAttachmentIds addObject:contactShare.avatarAttachmentId]; } }]; }]; if (shouldAbort) { return nil; } DDLogDebug(@"%@ fileCount: %zu", self.logTag, fileCount); DDLogDebug(@"%@ totalFileSize: %lld", self.logTag, totalFileSize.longLongValue); DDLogDebug(@"%@ attachmentStreams: %d", self.logTag, attachmentStreamCount); DDLogDebug(@"%@ attachmentStreams with file paths: %zu", self.logTag, allAttachmentFilePaths.count); NSMutableSet *orphanFilePaths = [allOnDiskFilePaths mutableCopy]; [orphanFilePaths minusSet:allAttachmentFilePaths]; [orphanFilePaths minusSet:profileAvatarFilePaths]; NSMutableSet *missingAttachmentFilePaths = [allAttachmentFilePaths mutableCopy]; [missingAttachmentFilePaths minusSet:allOnDiskFilePaths]; DDLogDebug(@"%@ orphan file paths: %zu", self.logTag, orphanFilePaths.count); DDLogDebug(@"%@ missing attachment file paths: %zu", self.logTag, missingAttachmentFilePaths.count); [self printPaths:orphanFilePaths.allObjects label:@"orphan file paths"]; [self printPaths:missingAttachmentFilePaths.allObjects label:@"missing attachment file paths"]; DDLogDebug(@"%@ attachmentIds: %zu", self.logTag, allAttachmentIds.count); DDLogDebug(@"%@ messageAttachmentIds: %zu", self.logTag, messageAttachmentIds.count); DDLogDebug(@"%@ quotedReplyThumbnailAttachmentIds: %zu", self.logTag, quotedReplyThumbnailAttachmentIds.count); DDLogDebug(@"%@ contactShareAvatarAttachmentIds: %zu", self.logTag, contactShareAvatarAttachmentIds.count); NSMutableSet *orphanAttachmentIds = [allAttachmentIds mutableCopy]; [orphanAttachmentIds minusSet:messageAttachmentIds]; [orphanAttachmentIds minusSet:quotedReplyThumbnailAttachmentIds]; [orphanAttachmentIds minusSet:contactShareAvatarAttachmentIds]; NSMutableSet *missingAttachmentIds = [messageAttachmentIds mutableCopy]; [missingAttachmentIds minusSet:allAttachmentIds]; DDLogDebug(@"%@ orphan attachmentIds: %zu", self.logTag, orphanAttachmentIds.count); DDLogDebug(@"%@ missing attachmentIds: %zu", self.logTag, missingAttachmentIds.count); DDLogDebug(@"%@ orphan interactions: %zu", self.logTag, orphanInteractionIds.count); OWSOrphanData *result = [OWSOrphanData new]; result.interactionIds = [orphanInteractionIds copy]; result.attachmentIds = [orphanAttachmentIds copy]; result.filePaths = [orphanFilePaths copy]; return result; } + (void)auditOnLaunchIfNecessary { OWSAssertIsOnMainThread(); OWSPrimaryStorage *primaryStorage = [OWSPrimaryStorage sharedManager]; YapDatabaseConnection *databaseConnection = primaryStorage.dbReadWriteConnection; // In production, only clean up once per app version. #ifndef DEBUG NSString *_Nullable lastCleaningVersion = [databaseConnection stringForKey:OWSOrphanDataCleaner_LastCleaningVersionKey inCollection:OWSOrphanDataCleaner_Collection]; NSString *currentAppVersion = AppVersion.sharedInstance.currentAppVersion; if (lastCleaningVersion && [lastCleaningVersion isEqualToString:currentAppVersion]) { DDLogVerbose(@"%@ skipping orphan data cleanup; already done on %@.", self.logTag, currentAppVersion); return; } #endif // 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]; } + (void)auditAndCleanup:(BOOL)shouldRemoveOrphans { OWSPrimaryStorage *primaryStorage = [OWSPrimaryStorage sharedManager]; YapDatabaseConnection *databaseConnection = primaryStorage.dbReadWriteConnection; [self auditAndCleanup:shouldRemoveOrphans databaseConnection:databaseConnection]; } // 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 { OWSAssertIsOnMainThread(); OWSAssert(databaseConnection); if (!AppReadiness.isAppReady) { OWSProdLogAndFail(@"%@ can't audit orphan data until app is ready.", self.logTag); return; } if (!CurrentAppContext().isMainApp) { OWSProdLogAndFail(@"%@ can't audit orphan data in app extensions.", self.logTag); 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:^{ DDLogInfo(@"%@ Completed orphan data cleanup.", self.logTag); [databaseConnection setObject:AppVersion.sharedInstance.currentAppVersion forKey:OWSOrphanDataCleaner_LastCleaningVersionKey inCollection:OWSOrphanDataCleaner_Collection]; } failure:^{ DDLogInfo(@"%@ Aborting orphan data cleanup.", self.logTag); }]; } failure:^{ DDLogInfo(@"%@ Aborting orphan data cleanup.", self.logTag); }]; } // 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 { OWSAssert(databaseConnection); OWSAssert(orphanData); if (remainingRetries < 1) { DDLogInfo(@"%@ Aborting orphan data audit.", self.logTag); 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 { OWSAssert(databaseConnection); OWSAssert(orphanData); __block BOOL shouldAbort = NO; // We need to avoid cleaning up new attachments and 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 = AppVersion.sharedInstance.appLaunchTime; NSTimeInterval thresholdTimestamp = appLaunchTime.timeIntervalSince1970 - kMinimumOrphanAgeSeconds; NSDate *thresholdDate = [NSDate dateWithTimeIntervalSince1970:thresholdTimestamp]; [databaseConnection readWriteWithBlock:^(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. DDLogWarn(@"%@ Could not load interaction: %@", self.logTag, 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]) { DDLogInfo(@"%@ Skipping orphan interaction due to age: %f", self.logTag, fabs(creationDate.timeIntervalSinceNow)); continue; } DDLogInfo(@"%@ Removing orphan message: %@", self.logTag, interaction.uniqueId); interactionsRemoved++; if (!shouldRemoveOrphans) { continue; } [interaction removeWithTransaction:transaction]; } DDLogInfo(@"%@ Deleted orphan interactions: %zu", self.logTag, 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. DDLogWarn(@"%@ Could not load attachment: %@", self.logTag, 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]) { DDLogInfo(@"%@ Skipping orphan attachment due to age: %f", self.logTag, fabs(creationDate.timeIntervalSinceNow)); continue; } DDLogInfo(@"%@ Removing orphan attachmentStream: %@", self.logTag, attachmentStream.uniqueId); attachmentsRemoved++; if (!shouldRemoveOrphans) { continue; } [attachmentStream removeWithTransaction:transaction]; } DDLogInfo(@"%@ Deleted orphan attachments: %zu", self.logTag, attachmentsRemoved); }]; if (shouldAbort) { return nil; } NSUInteger filesRemoved = 0; NSArray *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) { OWSProdLogAndFail(@"%@ Could not get attributes of file at: %@", self.logTag, filePath); continue; } // Don't delete files which were created in the last N minutes. NSDate *creationDate = attributes.fileModificationDate; if ([creationDate isAfterDate:thresholdDate]) { DDLogInfo(@"%@ Skipping orphan attachment file due to age: %f", self.logTag, fabs([creationDate timeIntervalSinceNow])); continue; } DDLogInfo(@"%@ Deleting orphan attachment file: %@", self.logTag, filePath); filesRemoved++; if (!shouldRemoveOrphans) { continue; } [[NSFileManager defaultManager] removeItemAtPath:filePath error:&error]; if (error) { OWSProdLogAndFail(@"%@ Could not remove orphan file at: %@", self.logTag, filePath); } } DDLogInfo(@"%@ Deleted orphan attachment files: %zu", self.logTag, filesRemoved); return YES; } @end NS_ASSUME_NONNULL_END