Signal-iOS/Signal/OrphanData/OWSOrphanDataCleaner.swift
2024-06-04 14:58:58 -07:00

958 lines
39 KiB
Swift

//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalCoreKit
import SignalServiceKit
import SignalUI
private enum Constants {
static let lastCleaningVersionKey = "OWSOrphanDataCleaner_LastCleaningVersionKey"
static let lastCleaningDateKey = "OWSOrphanDataCleaner_LastCleaningDateKey"
}
private struct OWSOrphanData {
let interactionIds: Set<String>
let attachmentIds: Set<String>
let filePaths: Set<String>
let reactionIds: Set<String>
let mentionIds: Set<String>
let fileAndDirectoryPaths: Set<String>
let hasOrphanedPacksOrStickers: Bool
}
private typealias OrphanDataBlock = (_ orphanData: OWSOrphanData) -> Void
// Notes:
//
// * On disk, we only bother cleaning up files, not directories.
enum OWSOrphanDataCleaner {
/// Unlike CurrentAppContext().isMainAppAndActive, this method can be safely
/// invoked off the main thread.
private static var isMainAppAndActive: Bool {
CurrentAppContext().reportedApplicationState == .active
}
private static let databaseStorage = SDSDatabaseStorage.shared
private static let keyValueStore = SDSKeyValueStore(collection: "OWSOrphanDataCleaner_Collection")
/// We use the lowest priority possible.
private static let workQueue = DispatchQueue.global(qos: .background)
static func auditOnLaunchIfNecessary() {
AssertIsOnMainThread()
guard shouldAuditWithSneakyTransaction() else { return }
// If we want to be cautious, we can disable orphan deletion using
// flag - the cleanup will just be a dry run with logging.
let shouldCleanUp = true
auditAndCleanup(shouldCleanUp)
}
/// This is exposed for the debug UI and tests.
static func auditAndCleanup(_ shouldRemoveOrphans: Bool, completion: (() -> Void)? = nil) {
AssertIsOnMainThread()
guard AppReadiness.isAppReady else {
owsFailDebug("can't audit orphan data until app is ready.")
return
}
guard CurrentAppContext().isMainApp else {
owsFailDebug("can't audit orphan data in app extensions.")
return
}
Logger.info("Starting orphan data \(shouldRemoveOrphans ? "cleanup" : "audit")")
// 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.
let maxRetries = 3
findOrphanData(withRetries: maxRetries) { orphanData in
processOrphans(orphanData,
remainingRetries: maxRetries,
shouldRemoveOrphans: shouldRemoveOrphans) {
Logger.info("Completed orphan data cleanup.")
databaseStorage.write { transaction in
keyValueStore.setString(AppVersionImpl.shared.currentAppVersion,
key: Constants.lastCleaningVersionKey,
transaction: transaction)
keyValueStore.setDate(Date(),
key: Constants.lastCleaningDateKey,
transaction: transaction)
}
completion?()
} failure: {
Logger.info("Aborting orphan data cleanup.")
completion?()
}
} failure: {
Logger.info("Aborting orphan data cleanup.")
completion?()
}
}
private static func shouldAuditWithSneakyTransaction() -> Bool {
let kvs = keyValueStore
let currentAppVersion = AppVersionImpl.shared.currentAppVersion
return databaseStorage.read { transaction -> Bool in
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
guard tsAccountManager.registrationState(tx: transaction.asV2Read).isRegistered else {
return false
}
let lastCleaningVersion = kvs.getString(
Constants.lastCleaningVersionKey,
transaction: transaction
)
guard let lastCleaningVersion else {
Logger.info("Performing orphan data cleanup because we've never done it")
return true
}
guard lastCleaningVersion == currentAppVersion else {
Logger.info("Performing orphan data cleanup because we're on a different app version")
return true
}
let lastCleaningDate = kvs.getDate(
Constants.lastCleaningDateKey,
transaction: transaction
)
guard let lastCleaningDate else {
owsFailDebug("We have a \"last cleaned version\". Why don't we have a last cleaned date?")
Logger.info("Performing orphan data cleanup because we've never done it")
return true
}
#if DEBUG
let hasEnoughTimePassed = DateUtil.dateIsOlderThanToday
#else
let hasEnoughTimePassed = DateUtil.dateIsOlderThanOneWeek
#endif
if hasEnoughTimePassed(lastCleaningDate, nil) {
Logger.info("Performing orphan data cleanup because enough time has passed")
return true
}
return false
}
}
private static func findJobRecordAttachmentIds(transaction: SDSAnyReadTransaction) -> [String]? {
var attachmentIds = [String]()
var shouldAbort = false
func findAttachmentIds<JobRecordType: JobRecord>(
transaction: SDSAnyReadTransaction,
jobRecordAttachmentIds: (JobRecordType) -> some Sequence<String>
) {
do {
try JobRecordFinderImpl<JobRecordType>(db: DependenciesBridge.shared.db).enumerateJobRecords(
transaction: transaction.asV2Read,
block: { jobRecord, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop = true
return
}
attachmentIds.append(contentsOf: jobRecordAttachmentIds(jobRecord))
}
)
} catch {
Logger.warn("Couldn't enumerate job records: \(error)")
}
}
findAttachmentIds(
transaction: transaction,
jobRecordAttachmentIds: { (jobRecord: MessageSenderJobRecord) -> [String] in
guard let message = fetchMessage(for: jobRecord, transaction: transaction) else {
return []
}
return Self.legacyAttachmentUniqueIds(message)
}
)
if shouldAbort {
return nil
}
findAttachmentIds(
transaction: transaction,
jobRecordAttachmentIds: { (jobRecord: TSAttachmentMultisendJobRecord) in jobRecord.attachmentIdMap.keys }
)
if shouldAbort {
return nil
}
findAttachmentIds(
transaction: transaction,
jobRecordAttachmentIds: { (jobRecord: IncomingGroupSyncJobRecord) in [jobRecord.legacyAttachmentId].compacted() }
)
if shouldAbort {
return nil
}
findAttachmentIds(
transaction: transaction,
jobRecordAttachmentIds: { (jobRecord: IncomingContactSyncJobRecord) -> [String] in
switch jobRecord.downloadInfo {
case .invalid, .transient:
return []
case .legacy(let attachmentId):
return [attachmentId]
}
}
)
if shouldAbort {
return nil
}
return attachmentIds
}
private static func fetchMessage(
for jobRecord: MessageSenderJobRecord,
transaction: SDSAnyReadTransaction
) -> TSMessage? {
switch jobRecord.messageType {
case .none:
return nil
case .transient(let message):
return message
case .persisted(let messageId, _), .editMessage(let messageId, _, _):
guard let interaction = TSInteraction.anyFetch(uniqueId: messageId, transaction: transaction) else {
// Interaction may have been deleted.
Logger.warn("Missing interaction")
return nil
}
return interaction as? TSMessage
}
}
// MARK: - Find
/// This method finds but does not delete orphan data.
///
/// The follow items are considered orphan data:
/// * Orphan `TSInteraction`s (with no thread).
/// * Orphan `TSAttachment`s (with no message).
/// * Orphan attachment files (with no corresponding `TSAttachment`).
/// * Orphan profile avatars.
/// * Temporary files (all).
///
/// It also finds but we don't clean these up:
/// * Missing attachment files (cannot be cleaned up).
/// These are attachments which have no file on disk. The 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.
private static func findOrphanData(withRetries remainingRetries: Int,
success: @escaping OrphanDataBlock,
failure: @escaping () -> Void) {
guard remainingRetries > 0 else {
Logger.info("Aborting orphan data search. No more retries.")
workQueue.async(failure)
return
}
Logger.info("Enqueuing an orphan data search. Remaining retries: \(remainingRetries)")
// Wait until the app is active...
CurrentAppContext().runNowOrWhenMainAppIsActive {
// ...but perform the work off the main thread.
let backgroundTask = OWSBackgroundTask(label: #function)
workQueue.async {
if let orphanData = findOrphanDataSync() {
success(orphanData)
} else {
findOrphanData(withRetries: remainingRetries - 1, success: success, failure: failure)
}
backgroundTask.end()
}
}
}
/// 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.
private static func findOrphanDataSync() -> OWSOrphanData? {
var shouldAbort = false
let legacyAttachmentsDirPath = TSAttachmentStream.legacyAttachmentsDirPath()
let sharedDataAttachmentsDirPath = TSAttachmentStream.sharedDataAttachmentsDirPath()
guard let legacyAttachmentFilePaths = filePaths(inDirectorySafe: legacyAttachmentsDirPath), isMainAppAndActive else {
return nil
}
guard let sharedDataAttachmentFilePaths = filePaths(inDirectorySafe: sharedDataAttachmentsDirPath), isMainAppAndActive else {
return nil
}
let legacyProfileAvatarsDirPath = OWSUserProfile.legacyProfileAvatarsDirPath
let sharedDataProfileAvatarsDirPath = OWSUserProfile.sharedDataProfileAvatarsDirPath
guard let legacyProfileAvatarsFilePaths = filePaths(inDirectorySafe: legacyProfileAvatarsDirPath), isMainAppAndActive else {
return nil
}
guard let sharedDataProfileAvatarFilePaths = filePaths(inDirectorySafe: sharedDataProfileAvatarsDirPath), isMainAppAndActive else {
return nil
}
guard let allGroupAvatarFilePaths = filePaths(inDirectorySafe: TSGroupModel.avatarsDirectory.path), isMainAppAndActive else {
return nil
}
let stickersDirPath = StickerManager.cacheDirUrl().path
guard let allStickerFilePaths = filePaths(inDirectorySafe: stickersDirPath), isMainAppAndActive else {
return nil
}
let allOnDiskFilePaths: Set<String> = {
var result: Set<String> = []
result.formUnion(legacyAttachmentFilePaths)
result.formUnion(sharedDataAttachmentFilePaths)
result.formUnion(legacyProfileAvatarsFilePaths)
result.formUnion(sharedDataProfileAvatarFilePaths)
result.formUnion(allGroupAvatarFilePaths)
result.formUnion(allStickerFilePaths)
// TODO: Badges?
// This should be redundant, but this will future-proof us against
// ever accidentally removing the GRDB databases during
// orphan clean up.
let grdbPrimaryDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .primary).path
let grdbHotswapDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .hotswapLegacy).path
let grdbTransferDirectoryPath: String?
if GRDBDatabaseStorageAdapter.hasAssignedTransferDirectory && TSAccountManagerObjcBridge.isTransferInProgressWithMaybeTransaction {
grdbTransferDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .transfer).path
} else {
grdbTransferDirectoryPath = nil
}
let databaseFilePaths: Set<String> = {
var filePathsToSubtract: Set<String> = []
for filePath in result {
if filePath.hasPrefix(grdbPrimaryDirectoryPath) {
Logger.info("Protecting database file: \(filePath)")
filePathsToSubtract.insert(filePath)
} else if filePath.hasPrefix(grdbHotswapDirectoryPath) {
Logger.info("Protecting database hotswap file: \(filePath)")
filePathsToSubtract.insert(filePath)
} else if let grdbTransferDirectoryPath, filePath.hasPrefix(grdbTransferDirectoryPath) {
Logger.info("Protecting database hotswap file: \(filePath)")
filePathsToSubtract.insert(filePath)
}
}
return filePathsToSubtract
}()
result.subtract(databaseFilePaths)
return result
}()
let profileAvatarFilePaths: Set<String> = {
var result: Set<String> = []
databaseStorage.read { transaction in
result = OWSProfileManager.allProfileAvatarFilePaths(with: transaction)
}
return result
}()
guard let groupAvatarFilePaths = {
do {
var result: Set<String> = []
try databaseStorage.read { transaction in
result = try TSGroupModel.allGroupAvatarFilePaths(transaction: transaction)
}
return result
} catch {
owsFailDebug("Failed to query group avatar file paths \(error)")
return nil
}
}() else {
return nil
}
guard isMainAppAndActive else {
return nil
}
let voiceMessageDraftOrphanedPaths = findOrphanedVoiceMessageDraftPaths()
guard isMainAppAndActive else {
return nil
}
let wallpaperOrphanedPaths = findOrphanedWallpaperPaths()
guard isMainAppAndActive else {
return nil
}
// Attachments
var attachmentStreamCount: Int = 0
var allAttachmentFilePaths: Set<String> = []
var allAttachmentIds: Set<String> = []
var allReactionIds: Set<String> = []
var allMentionIds: Set<String> = []
var orphanInteractionIds: Set<String> = []
var allMessageAttachmentIds: Set<String> = []
var allStoryAttachmentIds: Set<String> = []
var allMessageReactionIds: Set<String> = []
var allMessageMentionIds: Set<String> = []
var activeStickerFilePaths: Set<String> = []
var hasOrphanedPacksOrStickers = false
databaseStorage.read { transaction in
TSAttachmentStream.anyEnumerate(transaction: transaction, batched: true) { attachment, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop.pointee = true
return
}
guard let attachmentStream = attachment as? TSAttachmentStream else {
return
}
allAttachmentIds.insert(attachment.uniqueId)
attachmentStreamCount += 1
if let filePath = attachmentStream.originalFilePath {
allAttachmentFilePaths.insert(filePath)
} else {
owsFailDebug("attachment has no file path.")
}
allAttachmentFilePaths.formUnion(attachmentStream.allSecondaryFilePaths())
}
if shouldAbort {
return
}
let threadIds: Set<String> = Set(TSThread.anyAllUniqueIds(transaction: transaction))
var allInteractionIds: Set<String> = []
TSInteraction.anyEnumerate(transaction: transaction, batched: true) { interaction, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop.pointee = true
return
}
if interaction.uniqueThreadId.isEmpty || !threadIds.contains(interaction.uniqueThreadId) {
orphanInteractionIds.insert(interaction.uniqueId)
}
allInteractionIds.insert(interaction.uniqueId)
guard let message = interaction as? TSMessage else {
return
}
allMessageAttachmentIds.formUnion(legacyAttachmentUniqueIds(message))
}
if shouldAbort {
return
}
OWSReaction.anyEnumerate(transaction: transaction, batchingPreference: .batched()) { reaction, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop.pointee = true
return
}
allReactionIds.insert(reaction.uniqueId)
if allInteractionIds.contains(reaction.uniqueMessageId) {
allMessageReactionIds.insert(reaction.uniqueId)
}
}
if shouldAbort {
return
}
TSMention.anyEnumerate(transaction: transaction, batchingPreference: .batched()) { mention, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop.pointee = true
return
}
allMentionIds.insert(mention.uniqueId)
if allInteractionIds.contains(mention.uniqueMessageId) {
allMessageMentionIds.insert(mention.uniqueId)
}
}
if shouldAbort {
return
}
StoryMessage.anyEnumerate(transaction: transaction, batchingPreference: .batched()) { message, stop in
guard isMainAppAndActive else {
shouldAbort = true
stop.pointee = true
return
}
if let attachmentUniqueId = legacyAttachmentUniqueId(message) {
allStoryAttachmentIds.insert(attachmentUniqueId)
}
}
if shouldAbort {
return
}
guard let jobRecordAttachmentIds = findJobRecordAttachmentIds(transaction: transaction) else {
shouldAbort = true
return
}
allMessageAttachmentIds.formUnion(jobRecordAttachmentIds)
activeStickerFilePaths.formUnion(StickerManager.filePathsForAllInstalledStickers(transaction: transaction))
hasOrphanedPacksOrStickers = StickerManager.hasOrphanedData(tx: transaction)
}
if shouldAbort {
return nil
}
var orphanFilePaths = allOnDiskFilePaths
orphanFilePaths.subtract(allAttachmentFilePaths)
orphanFilePaths.subtract(profileAvatarFilePaths)
orphanFilePaths.subtract(groupAvatarFilePaths)
orphanFilePaths.subtract(activeStickerFilePaths)
var missingAttachmentFilePaths = allAttachmentFilePaths
missingAttachmentFilePaths.subtract(allOnDiskFilePaths)
var orphanAttachmentIds = allAttachmentIds
orphanAttachmentIds.subtract(allMessageAttachmentIds)
orphanAttachmentIds.subtract(allStoryAttachmentIds)
var missingAttachmentIds = allMessageAttachmentIds
missingAttachmentIds.subtract(allAttachmentIds)
var orphanReactionIds = allReactionIds
orphanReactionIds.subtract(allMessageReactionIds)
var missingReactionIds = allMessageReactionIds
missingReactionIds.subtract(allReactionIds)
var orphanMentionIds = allMentionIds
orphanMentionIds.subtract(allMessageMentionIds)
var missingMentionIds = allMessageMentionIds
missingMentionIds.subtract(allMentionIds)
var orphanFileAndDirectoryPaths: Set<String> = []
orphanFileAndDirectoryPaths.formUnion(voiceMessageDraftOrphanedPaths)
orphanFileAndDirectoryPaths.formUnion(wallpaperOrphanedPaths)
return OWSOrphanData(interactionIds: orphanInteractionIds,
attachmentIds: orphanAttachmentIds,
filePaths: orphanFilePaths,
reactionIds: orphanReactionIds,
mentionIds: orphanMentionIds,
fileAndDirectoryPaths: orphanFileAndDirectoryPaths,
hasOrphanedPacksOrStickers: hasOrphanedPacksOrStickers)
}
/// Finds paths in `baseUrl` not present in `fetchExpectedRelativePaths()`.
private static func findOrphanedPaths(
baseUrl: URL,
fetchExpectedRelativePaths: (SDSAnyReadTransaction) -> Set<String>
) -> Set<String> {
let basePath = baseUrl.path
// The ordering within this method is important. First, we search the file
// system for files that already exist. Next, we ensure that any pending
// database write operations have finished. This ensures that any files
// written as part of a database transaction are visible to our read
// transaction. If we skip the write transaction, we may treat just-created
// files as orphaned and remove them. If a new write transaction is opened
// after the one in this method, we won't treat any files it creates as
// orphaned since we've already finished searching the file system.
// Finally, we consult the database to see which files should exist.
let actualRelativePaths: [String]
do {
actualRelativePaths = try FileManager.default.subpathsOfDirectory(atPath: basePath)
} catch CocoaError.fileReadNoSuchFile {
actualRelativePaths = []
} catch {
Logger.warn("Orphan data cleaner couldn't find any paths \(error.shortDescription)")
actualRelativePaths = []
}
if actualRelativePaths.isEmpty {
return []
}
databaseStorage.write { _ in }
var expectedRelativePaths = databaseStorage.read { fetchExpectedRelativePaths($0) }
// Mark the directories that contain these files as expected as well. This
// avoids redundant `rmdir` calls to check if the directories are empty.
while true {
let oldCount = expectedRelativePaths.count
expectedRelativePaths.formUnion(expectedRelativePaths.lazy.map {
($0 as NSString).deletingLastPathComponent
})
let newCount = expectedRelativePaths.count
if oldCount == newCount {
break
}
}
let orphanedRelativePaths = Set(actualRelativePaths).subtracting(expectedRelativePaths)
return Set(orphanedRelativePaths.lazy.map { basePath.appendingPathComponent($0) })
}
private static func findOrphanedVoiceMessageDraftPaths() -> Set<String> {
findOrphanedPaths(
baseUrl: VoiceMessageInterruptedDraftStore.draftVoiceMessageDirectory,
fetchExpectedRelativePaths: {
VoiceMessageInterruptedDraftStore.allDraftFilePaths(transaction: $0)
}
)
}
private static func findOrphanedWallpaperPaths() -> Set<String> {
findOrphanedPaths(
baseUrl: LegacyWallpaperImageStore.customPhotoDirectory,
fetchExpectedRelativePaths: { LegacyWallpaperImageStore.allCustomPhotoRelativePaths(tx: $0.asV2Read) }
)
}
// MARK: - Remove
/// Calls `failure` on exhausting all remaining retries, 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.
private static func processOrphans(_ orphanData: OWSOrphanData,
remainingRetries: Int,
shouldRemoveOrphans: Bool,
success: @escaping () -> Void,
failure: @escaping () -> Void) {
guard remainingRetries > 0 else {
Logger.info("Aborting orphan data audit.")
workQueue.async(failure)
return
}
// Wait until the app is active...
CurrentAppContext().runNowOrWhenMainAppIsActive {
// ...but perform the work off the main thread.
let backgroundTask = OWSBackgroundTask(label: #function)
workQueue.async {
let result = processOrphansSync(orphanData, shouldRemoveOrphans: shouldRemoveOrphans)
if result {
success()
} else {
processOrphans(orphanData,
remainingRetries: remainingRetries - 1,
shouldRemoveOrphans: shouldRemoveOrphans,
success: success,
failure: failure)
}
backgroundTask.end()
}
}
}
/// Returns `false` 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.
private static func processOrphansSync(_ orphanData: OWSOrphanData, shouldRemoveOrphans: Bool) -> Bool {
guard isMainAppAndActive else {
return false
}
var shouldAbort = false
// 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.
let minimumOrphanAgeSeconds: TimeInterval = CurrentAppContext().isRunningTests ? 0 : 15 * kMinuteInterval
let appLaunchTime = CurrentAppContext().appLaunchTime
let thresholdTimestamp = appLaunchTime.timeIntervalSince1970 - minimumOrphanAgeSeconds
let thresholdDate = Date(timeIntervalSince1970: thresholdTimestamp)
databaseStorage.write { transaction in
var interactionsRemoved: UInt = 0
for interactionId in orphanData.interactionIds {
guard isMainAppAndActive else {
shouldAbort = true
return
}
guard let interaction = TSInteraction.anyFetch(uniqueId: interactionId, transaction: transaction) else {
// This could just be a race condition, but it should be very unlikely.
Logger.warn("Could not load interaction: \(interactionId)")
continue
}
// Don't delete interactions which were created in the last N minutes.
let creationDate = NSDate.ows_date(withMillisecondsSince1970: interaction.timestamp) as Date
guard creationDate <= thresholdDate else {
Logger.info("Skipping orphan interaction due to age: \(creationDate.timeIntervalSinceNow)")
continue
}
Logger.info("Removing orphan message: \(interaction.uniqueId)")
interactionsRemoved += 1
guard shouldRemoveOrphans else {
continue
}
DependenciesBridge.shared.interactionDeleteManager
.delete(interaction, sideEffects: .default(), tx: transaction.asV2Write)
}
Logger.info("Deleted orphan interactions: \(interactionsRemoved)")
var attachmentsRemoved: UInt = 0
for attachmentId in orphanData.attachmentIds {
guard isMainAppAndActive else {
shouldAbort = true
return
}
guard let attachment = TSAttachment.anyFetch(uniqueId: attachmentId, transaction: transaction) else {
// 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.
Logger.warn("Could not load attachment: \(attachmentId)")
continue
}
guard let attachmentStream = attachment as? TSAttachmentStream else {
continue
}
// Don't delete attachments which were created in the last N minutes.
let creationDate = attachmentStream.creationTimestamp
guard creationDate <= thresholdDate else {
Logger.info("Skipping orphan attachment due to age: \(creationDate.timeIntervalSinceNow)")
continue
}
Logger.info("Removing orphan attachmentStream: \(attachmentStream.uniqueId)")
attachmentsRemoved += 1
guard shouldRemoveOrphans else {
continue
}
attachmentStream.anyRemove(transaction: transaction)
}
Logger.info("Deleted orphan attachments: \(attachmentsRemoved)")
var reactionsRemoved: UInt = 0
for reactionId in orphanData.reactionIds {
guard isMainAppAndActive else {
shouldAbort = true
return
}
let performedCleanup = ReactionManager.tryToCleanupOrphanedReaction(uniqueId: reactionId,
thresholdDate: thresholdDate,
shouldPerformRemove: shouldRemoveOrphans,
transaction: transaction)
if performedCleanup {
reactionsRemoved += 1
}
}
Logger.info("Deleted orphan reactions: \(reactionsRemoved)")
var mentionsRemoved: UInt = 0
for mentionId in orphanData.mentionIds {
guard isMainAppAndActive else {
shouldAbort = true
return
}
let performedCleanup = MentionFinder.tryToCleanupOrphanedMention(uniqueId: mentionId,
thresholdDate: thresholdDate,
shouldPerformRemove: shouldRemoveOrphans,
transaction: transaction)
if performedCleanup {
mentionsRemoved += 1
}
}
Logger.info("Deleted orphan mentions: \(mentionsRemoved)")
if orphanData.hasOrphanedPacksOrStickers {
StickerManager.cleanUpOrphanedData(tx: transaction)
}
}
guard !shouldAbort else {
return false
}
var filesRemoved: UInt = 0
let filePaths = orphanData.filePaths.sorted()
for filePath in filePaths {
guard isMainAppAndActive else {
return false
}
guard let attributes = try? FileManager.default.attributesOfItem(atPath: filePath) else {
// This is fine; the file may have been deleted since we found it.
Logger.warn("Could not get attributes of file at: \(filePath)")
continue
}
// Don't delete files which were created in the last N minutes.
if let creationDate = (attributes as NSDictionary).fileModificationDate(), creationDate > thresholdDate {
Logger.info("Skipping file due to age: \(creationDate.timeIntervalSinceNow)")
continue
}
Logger.info("Deleting file: \(filePath)")
filesRemoved += 1
guard shouldRemoveOrphans else {
continue
}
guard OWSFileSystem.fileOrFolderExists(atPath: filePath) else {
// Already removed.
continue
}
if !OWSFileSystem.deleteFile(filePath, ignoreIfMissing: true) {
owsFailDebug("Could not remove orphan file")
}
}
Logger.info("Deleted orphan files: \(filesRemoved)")
if shouldRemoveOrphans {
guard removeOrphanedFileAndDirectoryPaths(orphanData.fileAndDirectoryPaths) else {
return false
}
}
return true
}
private static func removeOrphanedFileAndDirectoryPaths(_ fileAndDirectoryPaths: Set<String>) -> Bool {
var successCount = 0
var errorCount = 0
// Sort by longest path to shortest path so that we remove files before we
// try to remove the directories that contain them.
for fileOrDirectoryPath in fileAndDirectoryPaths.sorted(by: { $0.count < $1.count }).reversed() {
if !self.isMainAppAndActive {
return false
}
do {
try removeFileOrEmptyDirectory(at: fileOrDirectoryPath)
successCount += 1
} catch {
owsFailDebug("Couldn't remove file or directory: \(error.shortDescription)")
errorCount += 1
}
}
Logger.info("Deleted orphaned files/directories [successes: \(successCount), failures: \(errorCount)]")
return true
}
private static func removeFileOrEmptyDirectory(at path: String) throws {
do {
// First, remove it if it's a directory.
try runUnixOperation(rmdir, argument: path)
} catch POSIXError.ENOENT {
// It doesn't exist (or a parent directory doesn't exist).
return
} catch POSIXError.ENOTEMPTY {
// It's not empty, so don't delete it.
return
} catch POSIXError.ENOTDIR {
// It's a file.
} catch {
Logger.warn("Couldn't remove directory \(error.shortDescription)")
// Fall through since it seems like this isn't a directory...
}
do {
try runUnixOperation(unlink, argument: path)
} catch POSIXError.ENOTDIR, POSIXError.ENOENT {
// The file (or its containing directory) doesn't exist.
return
} catch {
throw error
}
}
private static func runUnixOperation(_ op: (UnsafePointer<CChar>?) -> Int32, argument path: String) throws {
let result = path.withCString { op($0) }
if result == 0 {
return
}
if let errorCode = POSIXErrorCode(rawValue: errno) {
throw POSIXError(errorCode)
}
throw OWSGenericError("Operation failed.")
}
// MARK: - Helpers
private static func legacyAttachmentUniqueIds(_ message: TSMessage) -> [String] {
let ids = TSAttachmentStore().allAttachmentIds(for: message)
return Array(ids)
}
private static func legacyAttachmentUniqueId(_ storyMessage: StoryMessage) -> String? {
switch storyMessage.attachment {
case .file(let file):
return file.attachmentId
case .text(let textAttachment):
return textAttachment.preview?.legacyImageAttachmentId
case .foreignReferenceAttachment:
return nil
}
}
private static func filePaths(inDirectorySafe dirPath: String) -> Set<String>? {
guard FileManager.default.fileExists(atPath: dirPath) else {
return []
}
do {
var result: Set<String> = []
let fileNames = try FileManager.default.contentsOfDirectory(atPath: dirPath)
for fileName in fileNames {
guard isMainAppAndActive else {
return nil
}
let filePath = dirPath.appendingPathComponent(fileName)
var isDirectory: ObjCBool = false
if FileManager.default.fileExists(atPath: filePath, isDirectory: &isDirectory) {
if isDirectory.boolValue {
guard let dirPaths = filePaths(inDirectorySafe: filePath) else {
return nil
}
result.formUnion(dirPaths)
} else {
result.insert(filePath)
}
}
}
return result
} catch {
switch error {
case POSIXError.ENOENT, CocoaError.fileReadNoSuchFile:
// Races may cause files to be removed while we crawl the directory contents.
Logger.warn("Error: \(error)")
default:
owsFailDebug("Error: \(error)")
}
return []
}
}
}