Files
Morgan Pretty c25071f73f Fixed more QA issues
• Added more details to profile update logs (also added debug logs for updating other users profiles)
• Added a delay before killing the app after clearing data to give the libSession logger more time to shutdown (false crash reports)
• Removed some emoji encoding which is no longer needed
• Fixed a couple of cases where we were trying to access the libSession cache for new accounts before it had been created
• Fixed a bug where we could try to render a profile picture for the current user even if it hadn't been downloaded yet (resulting in nothing being rendered)
• Fixed a bug where restoring an account would clear the users display picture
• Fixed a bug where we wouldn't immediately download the users display picture after restoring an account
• Fixed a bug where video attachments weren't getting their metadata extracted correctly (preventing them from being sent correctly)
• Fixed a bug where some video formats might not be recognised as video
• Fixed a bug preventing emoji reactions in communities
• Fixed a bug where portrait images wouldn't crop correctly
2025-10-21 12:51:14 +11:00

573 lines
32 KiB
Swift

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import SessionUtilitiesKit
import SessionNetworkingKit
// MARK: - Log.Category
private extension Log.Category {
static let cat: Log.Category = .create("GarbageCollectionJob", defaultLevel: .info)
}
// MARK: - GarbageCollectionJob
/// This job deletes unused and orphaned data from the database as well as orphaned files from device storage
///
/// **Note:** When sheduling this job if no `Details` are provided (with a list of `typesToCollect`) then this job will
/// assume that it should be collecting all `Types`
public enum GarbageCollectionJob: JobExecutor {
public static var maxFailureCount: Int = -1
public static var requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false
public static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60)
public static let fourteenDaysInSeconds: TimeInterval = (14 * 24 * 60 * 60)
private static let minInteractionsToTrim: Int = 2000
private struct FileInfo {
let attachmentDownloadUrls: Set<String>
let displayPictureFilePaths: Set<String>
let messageDedupeRecords: [MessageDeduplication]
}
public static func run<S: Scheduler>(
_ job: Job,
scheduler: S,
success: @escaping (Job, Bool) -> Void,
failure: @escaping (Job, Error, Bool) -> Void,
deferred: @escaping (Job) -> Void,
using dependencies: Dependencies
) {
/// Determine what types of data we want to collect (if we didn't provide any then assume we want to collect everything)
///
/// **Note:** The reason we default to handle all cases (instead of just doing nothing in that case) is so the initial registration
/// of the garbageCollection job never needs to be updated as we continue to add more types going forward
let typesToCollect: [Types] = (job.details
.map { try? JSONDecoder(using: dependencies).decode(Details.self, from: $0) }?
.typesToCollect)
.defaulting(to: Types.allCases)
let timestampNow: TimeInterval = dependencies.dateNow.timeIntervalSince1970
/// Only do a full collection if the job isn't the recurring one or it's been 23 hours since it last ran (23 hours so a user who opens the
/// app at about the same time every day will trigger the garbage collection) - since this runs when the app becomes active we
/// want to prevent it running to frequently (the app becomes active if a system alert, the notification center or the control panel
/// are shown)
let lastGarbageCollection: Date = dependencies[defaults: .standard, key: .lastGarbageCollection]
.defaulting(to: Date.distantPast)
let finalTypesToCollect: Set<Types> = {
guard
job.behaviour != .recurringOnActive ||
dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60)
else {
// Note: This should only contain the `Types` which are unlikely to ever cause
// a startup delay (ie. avoid mass deletions and file management)
return typesToCollect.asSet()
.intersection([
.threadTypingIndicators
])
}
return typesToCollect.asSet()
}()
dependencies[singleton: .storage].writeAsync(
updates: { db -> FileInfo in
let userSessionId: SessionId = dependencies[cache: .general].sessionId
/// Remove any typing indicators
if finalTypesToCollect.contains(.threadTypingIndicators) {
let threadIds: Set<String> = try ThreadTypingIndicator
.select(.threadId)
.asRequest(of: String.self)
.fetchSet(db)
_ = try ThreadTypingIndicator.deleteAll(db)
/// Just in case we should emit events for each typing indicator to indicate that it should have stopped typing
threadIds.forEach { id in
db.addTypingIndicatorEvent(threadId: id, change: .stopped)
}
}
/// Remove any old open group messages - open group messages which are older than six months
if finalTypesToCollect.contains(.oldOpenGroupMessages) && dependencies.mutate(cache: .libSession, { $0.get(.trimOpenGroupMessagesOlderThanSixMonths) }) {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let threadIdLiteral: SQL = SQL(stringLiteral: Interaction.Columns.threadId.name)
let minInteractionsToTrimSql: SQL = SQL("\(GarbageCollectionJob.minInteractionsToTrim)")
try db.execute(literal: """
DELETE FROM \(Interaction.self)
WHERE \(Column.rowID) IN (
SELECT \(interaction[.rowId])
FROM \(Interaction.self)
JOIN \(SessionThread.self) ON (
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.community)")) AND
\(thread[.id]) = \(interaction[.threadId])
)
JOIN (
SELECT
COUNT(\(interaction[.rowId])) AS interactionCount,
\(interaction[.threadId])
FROM \(Interaction.self)
GROUP BY \(interaction[.threadId])
) AS interactionInfo ON interactionInfo.\(threadIdLiteral) = \(interaction[.threadId])
WHERE (
\(interaction[.timestampMs]) < \((timestampNow - approxSixMonthsInSeconds) * 1000) AND
interactionInfo.interactionCount >= \(minInteractionsToTrimSql)
)
)
""")
}
/// Orphaned jobs - jobs which have had their threads or interactions removed
if finalTypesToCollect.contains(.orphanedJobs) {
let job: TypedTableAlias<Job> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Job.self)
WHERE \(Column.rowID) IN (
SELECT \(job[.rowId])
FROM \(Job.self)
LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId])
LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId])
WHERE (
-- Never delete config sync jobs, even if their threads were deleted
\(SQL("\(job[.variant]) != \(Job.Variant.configurationSync)")) AND
(
\(job[.threadId]) IS NOT NULL AND
\(thread[.id]) IS NULL
) OR (
\(job[.interactionId]) IS NOT NULL AND
\(interaction[.id]) IS NULL
)
)
)
""")
}
/// Orphaned link previews - link previews which have no interactions with matching url & rounded timestamps
if finalTypesToCollect.contains(.orphanedLinkPreviews) {
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(LinkPreview.self)
WHERE \(Column.rowID) IN (
SELECT \(linkPreview[.rowId])
FROM \(LinkPreview.self)
LEFT JOIN \(Interaction.self) ON (
\(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND
\(Interaction.linkPreviewFilterLiteral())
)
WHERE \(interaction[.id]) IS NULL
)
""")
}
/// Orphaned open groups - open groups which are no longer associated to a thread (except for the session-run ones for which
/// we want cached image data even if the user isn't in the group)
if finalTypesToCollect.contains(.orphanedOpenGroups) {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(OpenGroup.self)
WHERE \(Column.rowID) IN (
SELECT \(openGroup[.rowId])
FROM \(OpenGroup.self)
LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(openGroup[.threadId])
WHERE (
\(thread[.id]) IS NULL AND
\(SQL("\(openGroup[.server]) != \(Network.SOGS.defaultServer.lowercased())"))
)
)
""")
}
/// Orphaned open group capabilities - capabilities which have no existing open groups with the same server
if finalTypesToCollect.contains(.orphanedOpenGroupCapabilities) {
let capability: TypedTableAlias<Capability> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Capability.self)
WHERE \(Column.rowID) IN (
SELECT \(capability[.rowId])
FROM \(Capability.self)
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.server]) = \(capability[.openGroupServer])
WHERE \(openGroup[.threadId]) IS NULL
)
""")
}
/// Orphaned blinded id lookups - lookups which have no existing threads or approval/block settings for either blinded/un-blinded id
if finalTypesToCollect.contains(.orphanedBlindedIdLookups) {
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(BlindedIdLookup.self)
WHERE \(Column.rowID) IN (
SELECT \(blindedIdLookup[.rowId])
FROM \(BlindedIdLookup.self)
LEFT JOIN \(SessionThread.self) ON (
\(thread[.id]) = \(blindedIdLookup[.blindedId]) OR
\(thread[.id]) = \(blindedIdLookup[.sessionId])
)
LEFT JOIN \(Contact.self) ON (
\(contact[.id]) = \(blindedIdLookup[.blindedId]) OR
\(contact[.id]) = \(blindedIdLookup[.sessionId])
)
WHERE (
\(thread[.id]) IS NULL AND
\(contact[.id]) IS NULL
)
)
""")
}
/// Approved blinded contact records - once a blinded contact has been approved there is no need to keep the blinded
/// contact record around anymore
if finalTypesToCollect.contains(.approvedBlindedContactRecords) {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Contact.self)
WHERE \(Column.rowID) IN (
SELECT \(contact[.rowId])
FROM \(Contact.self)
LEFT JOIN \(BlindedIdLookup.self) ON (
\(blindedIdLookup[.blindedId]) = \(contact[.id]) AND
\(blindedIdLookup[.sessionId]) IS NOT NULL
)
WHERE \(blindedIdLookup[.sessionId]) IS NOT NULL
)
""")
}
/// Orphaned attachments - attachments which have no related interactions, quotes or link previews
if finalTypesToCollect.contains(.orphanedAttachments) {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Attachment.self)
WHERE \(Column.rowID) IN (
SELECT \(attachment[.rowId])
FROM \(Attachment.self)
LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id])
LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
WHERE (
\(linkPreview[.url]) IS NULL AND
\(interactionAttachment[.attachmentId]) IS NULL
)
)
""")
}
if finalTypesToCollect.contains(.orphanedProfiles) {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let blindedIdLookup: TypedTableAlias<BlindedIdLookup> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Profile.self)
WHERE \(Column.rowID) IN (
SELECT \(profile[.rowId])
FROM \(Profile.self)
LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(profile[.id])
LEFT JOIN \(Interaction.self) ON \(interaction[.authorId]) = \(profile[.id])
LEFT JOIN \(Quote.self) ON \(quote[.authorId]) = \(profile[.id])
LEFT JOIN \(GroupMember.self) ON \(groupMember[.profileId]) = \(profile[.id])
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(profile[.id])
LEFT JOIN \(BlindedIdLookup.self) ON (
blindedIdLookup.blindedId = \(profile[.id]) OR
blindedIdLookup.sessionId = \(profile[.id])
)
WHERE (
\(thread[.id]) IS NULL AND
\(interaction[.authorId]) IS NULL AND
\(quote[.authorId]) IS NULL AND
\(groupMember[.profileId]) IS NULL AND
\(contact[.id]) IS NULL AND
\(blindedIdLookup[.blindedId]) IS NULL
)
)
""")
}
/// Remove interactions which should be disappearing after read but never be read within 14 days
if finalTypesToCollect.contains(.expiredUnreadDisappearingMessages) {
try Interaction.deleteWhere(
db,
.filter(Interaction.Columns.expiresInSeconds != 0),
.filter(Interaction.Columns.expiresStartedAtMs == nil),
.filter(Interaction.Columns.timestampMs < (timestampNow - fourteenDaysInSeconds) * 1000)
)
}
if finalTypesToCollect.contains(.expiredPendingReadReceipts) {
_ = try PendingReadReceipt
.filter(PendingReadReceipt.Columns.serverExpirationTimestamp <= timestampNow)
.deleteAll(db)
}
if finalTypesToCollect.contains(.shadowThreads) {
// Shadow threads are thread records which were created to start a conversation that
// didn't actually get turned into conversations (ie. the app was closed or crashed
// before the user sent a message)
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(SessionThread.self)
WHERE \(Column.rowID) IN (
SELECT \(thread[.rowId])
FROM \(SessionThread.self)
LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])
LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])
LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])
WHERE (
\(contact[.id]) IS NULL AND
\(openGroup[.threadId]) IS NULL AND
\(closedGroup[.threadId]) IS NULL AND
\(thread[.shouldBeVisible]) = false AND
\(SQL("\(thread[.id]) != \(userSessionId.hexString)"))
)
)
""")
}
if finalTypesToCollect.contains(.pruneExpiredLastHashRecords) {
// Delete any expired SnodeReceivedMessageInfo values associated to a specific node
try SnodeReceivedMessageInfo
.select(Column.rowID)
.filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (timestampNow * 1000))
.deleteAll(db)
}
/// Retrieve any files which need to be deleted
var attachmentDownloadUrls: Set<String> = []
var displayPictureFilePaths: Set<String> = []
var messageDedupeRecords: [MessageDeduplication] = []
/// Orphaned attachment files - attachment files which don't have an associated record in the database
if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
/// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage
/// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow
/// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running)
/// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed
attachmentDownloadUrls = try Attachment
.select(.downloadUrl)
.filter(Attachment.Columns.downloadUrl != nil)
.asRequest(of: String.self)
.fetchSet(db)
}
/// Orphaned display picture files - profile avatar files which don't have an associated record in the database
if finalTypesToCollect.contains(.orphanedDisplayPictures) {
displayPictureFilePaths.insert(
contentsOf: Set(try Profile
.select(.displayPictureUrl)
.filter(Profile.Columns.displayPictureUrl != nil)
.asRequest(of: String.self)
.fetchSet(db)
.compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) })
)
displayPictureFilePaths.insert(
contentsOf: Set(try ClosedGroup
.select(.displayPictureUrl)
.filter(ClosedGroup.Columns.displayPictureUrl != nil)
.asRequest(of: String.self)
.fetchSet(db)
.compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) })
)
displayPictureFilePaths.insert(
contentsOf: Set(try OpenGroup
.select(.displayPictureOriginalUrl)
.filter(OpenGroup.Columns.displayPictureOriginalUrl != nil)
.asRequest(of: String.self)
.fetchSet(db)
.compactMap { try? dependencies[singleton: .displayPictureManager].path(for: $0) })
)
}
if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) {
messageDedupeRecords = try MessageDeduplication
.filter(
MessageDeduplication.Columns.expirationTimestampSeconds != nil &&
MessageDeduplication.Columns.expirationTimestampSeconds < timestampNow
)
.fetchAll(db)
}
return FileInfo(
attachmentDownloadUrls: attachmentDownloadUrls,
displayPictureFilePaths: displayPictureFilePaths,
messageDedupeRecords: messageDedupeRecords
)
},
completion: { result in
guard case .success(let fileInfo) = result else {
return failure(job, StorageError.generic, false)
}
/// Dispatch async so we don't block the database threads while doing File I/O
scheduler.schedule {
var deletionErrors: [Error] = []
/// Orphaned attachment files (actual deletion)
if finalTypesToCollect.contains(.orphanedAttachmentFiles) {
let attachmentDirPath: String = dependencies[singleton: .attachmentManager]
.sharedDataAttachmentsDirPath()
let allAttachmentFilePaths: Set<String> = (Set((try? dependencies[singleton: .fileManager]
.contentsOfDirectory(atPath: attachmentDirPath))?
.map { filename in
URL(fileURLWithPath: attachmentDirPath)
.appendingPathComponent(filename)
.path
} ?? []))
let databaseAttachmentFilePaths: Set<String> = Set(fileInfo.attachmentDownloadUrls
.compactMap { try? dependencies[singleton: .attachmentManager].path(for: $0) })
let orphanedAttachmentFiles: Set<String> = allAttachmentFilePaths
.subtracting(databaseAttachmentFilePaths)
orphanedAttachmentFiles.forEach { filepath in
/// We don't want a single deletion failure to block deletion of the other files so try each one and store
/// the error to be used to determine success/failure of the job
do { try dependencies[singleton: .fileManager].removeItem(atPath: filepath) }
catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst
catch { deletionErrors.append(error) }
}
Log.info(.cat, "Orphaned attachments removed: \(orphanedAttachmentFiles.count)")
}
/// Orphaned display picture files (actual deletion)
if finalTypesToCollect.contains(.orphanedDisplayPictures) {
let allDisplayPictureFilePaths: Set<String> = (try? dependencies[singleton: .fileManager]
.contentsOfDirectory(atPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath()))
.defaulting(to: [])
.map { filename in
URL(fileURLWithPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath())
.appendingPathComponent(filename)
.path
}
.asSet()
let orphanedFilePaths: Set<String> = allDisplayPictureFilePaths
.subtracting(fileInfo.displayPictureFilePaths)
orphanedFilePaths.forEach { path in
/// We don't want a single deletion failure to block deletion of the other files so try each one and store
/// the error to be used to determine success/failure of the job
do { try dependencies[singleton: .fileManager].removeItem(atPath: path) }
catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst
catch { deletionErrors.append(error) }
}
Log.info(.cat, "Orphaned display pictures removed: \(orphanedFilePaths.count)")
}
/// Explicit deduplication records that we want to delete
if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) {
fileInfo.messageDedupeRecords.forEach { record in
/// We don't want a single deletion failure to block deletion of the other files so try each one and store
/// the error to be used to determine success/failure of the job
do {
try dependencies[singleton: .extensionHelper].removeDedupeRecord(
threadId: record.threadId,
uniqueIdentifier: record.uniqueIdentifier
)
}
catch CocoaError.fileNoSuchFile {} /// No need to do anything if the file doesn't eixst
catch { deletionErrors.append(error) }
}
Log.info(.cat, "Dedupe records removed: \(fileInfo.messageDedupeRecords.count)")
}
/// Report a single file deletion as a job failure (even if other content was successfully removed)
guard deletionErrors.isEmpty else {
failure(job, (deletionErrors.first ?? StorageError.generic), false)
return
}
/// Define a `successClosure` to avoid duplication
let successClosure: () -> Void = {
/// If we did a full collection then update the `lastGarbageCollection` date to prevent a full collection
/// from running again in the next 23 hours
if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) {
dependencies[defaults: .standard, key: .lastGarbageCollection] = dependencies.dateNow
}
success(job, false)
}
/// Since the explicit file deletion was successful we can now _actually_ delete the `MessageDeduplication`
/// entries from the database (we don't do this until after the files have been removed to ensure we don't orphan
/// files by doing so)
guard !fileInfo.messageDedupeRecords.isEmpty else { return successClosure() }
dependencies[singleton: .storage]
.writeAsync(
updates: { db in
try fileInfo.messageDedupeRecords.forEach { try $0.delete(db) }
},
completion: { result in
switch result {
case .failure: failure(job, StorageError.generic, false)
case .success: successClosure()
}
}
)
}
}
)
}
}
// MARK: - GarbageCollectionJob.Details
extension GarbageCollectionJob {
public enum Types: Codable, CaseIterable {
case threadTypingIndicators
case oldOpenGroupMessages
case orphanedJobs
case orphanedLinkPreviews
case orphanedOpenGroups
case orphanedOpenGroupCapabilities
case orphanedBlindedIdLookups
case approvedBlindedContactRecords
case orphanedProfiles
case orphanedAttachments
case orphanedAttachmentFiles
case orphanedDisplayPictures
case expiredUnreadDisappearingMessages // unread disappearing messages after 14 days
case expiredPendingReadReceipts
case shadowThreads
case pruneExpiredLastHashRecords
case pruneExpiredDeduplicationRecords
}
public struct Details: Codable {
public let typesToCollect: [Types]
public init(typesToCollect: [Types] = Types.allCases) {
self.typesToCollect = typesToCollect
}
}
}