Added logic for the GarbageCollectionJob

Fixed a bug where the GalleryRailView wasn't appearing
Fixed a database reentrancy error
Fixed a bug where the threadId for migrated attachmentUpload jobs wasn't getting set to the non-legacy version
Deleted the buggy overwritten 'delete' methods to avoid confusion
Updated the GroupMember table to cascade delete if it's thread is deleted (can't do so for the Closed/Open group but they share the same id conveniently)
Updated the 'isRunningTests' to be based on the existence of XCInjectBundleInfo which seems to be more consistent than the old approach
pull/612/head
Morgan Pretty 3 years ago
parent 59696f7d2c
commit 93b54a3b7d

@ -245,7 +245,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic
- (BOOL)isRunningTests - (BOOL)isRunningTests
{ {
return getenv("runningTests_dontStartApp"); return (NSProcessInfo.processInfo.environment[@"XCInjectBundleInto"] != nil);
} }
- (void)setNetworkActivityIndicatorVisible:(BOOL)value - (void)setNetworkActivityIndicatorVisible:(BOOL)value

@ -152,11 +152,13 @@ enum _001_InitialSetupMigration: Migration {
} }
try db.create(table: GroupMember.self) { t in try db.create(table: GroupMember.self) { t in
// Note: Not adding a "proper" foreign key constraint as this // Note: Since we don't know whether this will be stored against a 'ClosedGroup' or
// table gets used by both 'OpenGroup' and 'ClosedGroup' types // an 'OpenGroup' we add the foreign key constraint against the thread itself (which
// shares the same 'id' as the 'groupId') so we can cascade delete automatically
t.column(.groupId, .text) t.column(.groupId, .text)
.notNull() .notNull()
.indexed() // Quicker querying .indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if Thread deleted
t.column(.profileId, .text).notNull() t.column(.profileId, .text).notNull()
t.column(.role, .integer).notNull() t.column(.role, .integer).notNull()
} }
@ -316,7 +318,7 @@ enum _001_InitialSetupMigration: Migration {
t.column(.variant, .integer).notNull() t.column(.variant, .integer).notNull()
t.column(.title, .text) t.column(.title, .text)
t.column(.attachmentId, .text) t.column(.attachmentId, .text)
.references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted .references(Attachment.self) // Managed via garbage collection
t.primaryKey([.url, .timestamp]) t.primaryKey([.url, .timestamp])
} }

@ -48,8 +48,11 @@ enum _002_SetupStandardJobs: Migration {
_ = try Job( _ = try Job(
variant: .garbageCollection, variant: .garbageCollection,
behaviour: .recurringOnLaunch behaviour: .recurringOnLaunch,
).inserted(db) details: GarbageCollectionJob.Details(
typesToCollect: GarbageCollectionJob.Types.allCases
)
)?.inserted(db)
} }
GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration GRDBStorage.shared.update(progress: 1, for: self, in: target) // In case this is the last migration

@ -1207,12 +1207,12 @@ enum _003_YDBToGRDBMigration: Migration {
SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob") SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob")
throw StorageError.migrationFailed throw StorageError.migrationFailed
} }
let uploadJob: Job? = try Job( let uploadJob: Job? = try Job(
failureCount: legacyJob.failureCount, failureCount: legacyJob.failureCount,
variant: .attachmentUpload, variant: .attachmentUpload,
behaviour: .runOnce, behaviour: .runOnce,
threadId: legacyJob.threadID, threadId: sendJob.threadId,
interactionId: sendJob.interactionId, interactionId: sendJob.interactionId,
details: AttachmentUploadJob.Details( details: AttachmentUploadJob.Details(
messageSendJobId: sendJobId, messageSendJobId: sendJobId,

@ -219,28 +219,6 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
self.digest = nil self.digest = nil
self.caption = caption self.caption = caption
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// Delete all associated files
if FileManager.default.fileExists(atPath: thumbnailsDirPath) {
try? FileManager.default.removeItem(atPath: thumbnailsDirPath)
}
if
let legacyThumbnailPath: String = legacyThumbnailPath,
FileManager.default.fileExists(atPath: legacyThumbnailPath)
{
try? FileManager.default.removeItem(atPath: legacyThumbnailPath)
}
if let originalFilePath: String = originalFilePath {
try? FileManager.default.removeItem(atPath: originalFilePath)
}
return try performDelete(db)
}
} }
// MARK: - CustomStringConvertible // MARK: - CustomStringConvertible
@ -941,7 +919,7 @@ extension Attachment {
extension Attachment { extension Attachment {
internal func upload( internal func upload(
_ db: Database, _ db: Database? = nil,
using upload: (Data) -> Promise<UInt64>, using upload: (Data) -> Promise<UInt64>,
encrypt: Bool, encrypt: Bool,
success: (() -> Void)?, success: (() -> Void)?,
@ -977,9 +955,19 @@ extension Attachment {
digest == nil digest == nil
else { else {
// Save the final upload info // Save the final upload info
let uploadedAttachment: Attachment? = try? self let uploadedAttachment: Attachment? = {
.with(state: .uploaded) guard let db: Database = db else {
.saved(db) return GRDBStorage.shared.write { db in
try? self
.with(state: .uploaded)
.saved(db)
}
}
return try? self
.with(state: .uploaded)
.saved(db)
}()
guard uploadedAttachment != nil else { guard uploadedAttachment != nil else {
SNLog("Couldn't update attachmentUpload job.") SNLog("Couldn't update attachmentUpload job.")
@ -1019,9 +1007,19 @@ extension Attachment {
} }
// Update the attachment to the 'uploading' state // Update the attachment to the 'uploading' state
let updatedAttachment: Attachment? = try? processedAttachment let updatedAttachment: Attachment? = {
.with(state: .uploading) guard let db: Database = db else {
.saved(db) return GRDBStorage.shared.write { db in
try? processedAttachment
.with(state: .uploading)
.saved(db)
}
}
return try? processedAttachment
.with(state: .uploading)
.saved(db)
}()
guard updatedAttachment != nil else { guard updatedAttachment != nil else {
SNLog("Couldn't update attachmentUpload job.") SNLog("Couldn't update attachmentUpload job.")
@ -1061,6 +1059,7 @@ extension Attachment {
.with(state: .failedUpload) .with(state: .failedUpload)
.saved(db) .saved(db)
} }
failure?(error) failure?(error)
} }
} }

@ -76,16 +76,6 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe
self.name = name self.name = name
self.formationTimestamp = formationTimestamp self.formationTimestamp = formationTimestamp
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// Delete all 'GroupMember' records associated with this ClosedGroup (can't
// have a proper ForeignKey constraint as 'GroupMember' is reused for the
// 'OpenGroup' table as well)
try request(for: ClosedGroup.members).deleteAll(db)
return try performDelete(db)
}
} }
// MARK: - Mutation // MARK: - Mutation

@ -345,33 +345,6 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu
default: break default: break
} }
} }
public func delete(_ db: Database) throws -> Bool {
// If we have a LinkPreview then check if this is the only interaction that has it
// and delete the LinkPreview if so
if linkPreviewUrl != nil {
let interactionAlias: TableAlias = TableAlias()
let numInteractions: Int? = try? Interaction
.aliased(interactionAlias)
.joining(
required: Interaction.linkPreview
.filter(literal: Interaction.linkPreviewFilterLiteral())
)
.fetchCount(db)
let tmp = try linkPreview.fetchAll(db)
if numInteractions == 1 {
try linkPreview.deleteAll(db)
}
}
// Delete any jobs associated to this interaction
try Job
.filter(Job.Columns.interactionId == id)
.deleteAll(db)
return try performDelete(db)
}
} }
// MARK: - Mutation // MARK: - Mutation

@ -43,25 +43,4 @@ public struct InteractionAttachment: Codable, Equatable, FetchableRecord, Persis
self.interactionId = interactionId self.interactionId = interactionId
self.attachmentId = attachmentId self.attachmentId = attachmentId
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
let quoteUses: Int? = try? Quote
.select(.attachmentId)
.filter(Quote.Columns.attachmentId == attachmentId)
.fetchCount(db)
let linkPreviewUses: Int? = try? LinkPreview
.select(.attachmentId)
.filter(LinkPreview.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (quoteUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
return try performDelete(db)
}
} }

@ -72,27 +72,6 @@ public struct LinkPreview: Codable, Equatable, Hashable, FetchableRecord, Persis
self.title = title self.title = title
self.attachmentId = attachmentId self.attachmentId = attachmentId
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
if let attachmentId: String = attachmentId {
let interactionUses: Int? = try? InteractionAttachment
.filter(InteractionAttachment.Columns.attachmentId == attachmentId)
.fetchCount(db)
let quoteUses: Int? = try? Quote
.filter(Quote.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (interactionUses ?? 0) == 0 && (quoteUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
}
return try performDelete(db)
}
} }
// MARK: - Protobuf // MARK: - Protobuf

@ -104,16 +104,6 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco
self.userCount = userCount self.userCount = userCount
self.infoUpdates = infoUpdates self.infoUpdates = infoUpdates
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// Delete all 'GroupMember' records associated with this OpenGroup (can't
// have a proper ForeignKey constraint as 'GroupMember' is reused for the
// 'ClosedGroup' table as well)
try request(for: OpenGroup.members).deleteAll(db)
return try performDelete(db)
}
} }
// MARK: - Convenience // MARK: - Convenience

@ -74,27 +74,6 @@ public struct Quote: Codable, Equatable, Hashable, FetchableRecord, PersistableR
self.body = body self.body = body
self.attachmentId = attachmentId self.attachmentId = attachmentId
} }
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
if let attachmentId: String = attachmentId {
let interactionUses: Int? = try? InteractionAttachment
.filter(InteractionAttachment.Columns.attachmentId == attachmentId)
.fetchCount(db)
let linkPreviewUses: Int? = try? LinkPreview
.filter(LinkPreview.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (interactionUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
}
return try performDelete(db)
}
} }
// MARK: - Protobuf // MARK: - Protobuf

@ -129,22 +129,6 @@ public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord,
db[.hasSavedThread] = true db[.hasSavedThread] = true
} }
public func delete(_ db: Database) throws -> Bool {
// Delete any jobs associated to this thread
try Job
.filter(Job.Columns.threadId == id)
.deleteAll(db)
// Delete any GroupMembers associated to this thread
if variant == .closedGroup || variant == .openGroup {
try GroupMember
.filter(GroupMember.Columns.groupId == id)
.deleteAll(db)
}
return try performDelete(db)
}
} }
// MARK: - Mutation // MARK: - Mutation

@ -33,21 +33,21 @@ public enum AttachmentUploadJob: JobExecutor {
return return
} }
GRDBStorage.shared.writeAsync { db in // Note: In the AttachmentUploadJob we intentionally don't provide our own db instance to prevent reentrancy
attachment.upload( // issues when the success/failure closures get called before the upload as the JobRunner will attempt to
db, // update the state of the job immediately
using: { data in attachment.upload(
if let openGroup: OpenGroup = openGroup { using: { data in
return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) if let openGroup: OpenGroup = openGroup {
} return OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server)
}
return FileServerAPIV2.upload(data)
}, return FileServerAPIV2.upload(data)
encrypt: (openGroup == nil), },
success: { success(job, false) }, encrypt: (openGroup == nil),
failure: { error in failure(job, error, false) } success: { success(job, false) },
) failure: { error in failure(job, error, false) }
} )
} }
} }

@ -11,6 +11,7 @@ public enum GarbageCollectionJob: JobExecutor {
public static var maxFailureCount: Int = -1 public static var maxFailureCount: Int = -1
public static var requiresThreadId: Bool = false public static var requiresThreadId: Bool = false
public static let requiresInteractionId: Bool = false public static let requiresInteractionId: Bool = false
private static let approxSixMonthsInSeconds: TimeInterval = (6 * 30 * 24 * 60 * 60)
public static func run( public static func run(
_ job: Job, _ job: Job,
@ -26,7 +27,216 @@ public enum GarbageCollectionJob: JobExecutor {
return return
} }
failure(job, JobRunnerError.missingRequiredDetails, true) // If there are no types to collect then complete the job (and never run again - it doesn't do anything)
guard !details.typesToCollect.isEmpty else {
success(job, true)
return
}
let timestampNow: TimeInterval = Date().timeIntervalSince1970
var attachmentLocalRelativePaths: Set<String> = []
var profileAvatarFilenames: Set<String> = []
GRDBStorage.shared.writeAsync(
updates: { db in
// Remove any expired controlMessageProcessRecords
if details.typesToCollect.contains(.expiredControlMessageProcessRecords) {
_ = try ControlMessageProcessRecord
.filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow)
.deleteAll(db)
}
// Remove any typing indicators
if details.typesToCollect.contains(.threadTypingIndicators) {
_ = try ThreadTypingIndicator
.deleteAll(db)
}
// Remove any typing indicators
if details.typesToCollect.contains(.oldOpenGroupMessages) {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
try db.execute(literal: """
DELETE FROM \(Interaction.self)
WHERE \(Column.rowID) IN (
SELECT \(interaction.alias[Column.rowID])
FROM \(Interaction.self)
JOIN \(SessionThread.self) ON (
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND
\(thread[.id]) = \(interaction[.threadId])
)
WHERE \(interaction[.timestampMs]) < \(timestampNow - approxSixMonthsInSeconds)
)
""")
}
// Orphaned jobs
if details.typesToCollect.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.alias[Column.rowID])
FROM \(Job.self)
LEFT JOIN \(SessionThread.self) ON \(thread[.id]) = \(job[.threadId])
LEFT JOIN \(Interaction.self) ON \(interaction[.id]) = \(job[.interactionId])
WHERE (
(
\(job[.threadId]) IS NOT NULL AND
\(thread[.id]) IS NULL
) OR (
\(job[.interactionId]) IS NOT NULL AND
\(interaction[.id]) IS NULL
)
)
)
""")
}
// Orphaned link previews
if details.typesToCollect.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.alias[Column.rowID])
FROM \(LinkPreview.self)
LEFT JOIN \(Interaction.self) ON (
\(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND
\(Interaction.linkPreviewFilterLiteral())
)
WHERE \(interaction[.id]) IS NULL
)
""")
}
// Orphaned attachments
if details.typesToCollect.contains(.orphanedAttachments) {
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
let quote: TypedTableAlias<Quote> = 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.alias[Column.rowID])
FROM \(Attachment.self)
LEFT JOIN \(Quote.self) ON \(quote[.attachmentId]) = \(attachment[.id])
LEFT JOIN \(LinkPreview.self) ON \(linkPreview[.attachmentId]) = \(attachment[.id])
LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.attachmentId]) = \(attachment[.id])
WHERE (
\(quote[.attachmentId]) IS NULL AND
\(linkPreview[.url]) IS NULL AND
\(interactionAttachment[.attachmentId]) IS NULL
)
)
""")
}
// Orphaned attachment files
if details.typesToCollect.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
attachmentLocalRelativePaths = try Attachment
.select(.localRelativeFilePath)
.filter(Attachment.Columns.localRelativeFilePath != nil)
.asRequest(of: String.self)
.fetchSet(db)
}
// Orphaned profile avatar files
if details.typesToCollect.contains(.orphanedProfileAvatars) {
profileAvatarFilenames = try Profile
.select(.profilePictureFileName)
.filter(Profile.Columns.profilePictureFileName != nil)
.asRequest(of: String.self)
.fetchSet(db)
}
},
completion: { _, _ in
var deletionErrors: [Error] = []
// Orphaned attachment files (actual deletion)
if details.typesToCollect.contains(.orphanedAttachmentFiles) {
// Note: Looks like in order to recursively look through files we need to use the
// enumerator method
let fileEnumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: Attachment.attachmentsFolder),
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles // Ignore the `.DS_Store` for the simulator
)
let allAttachmentFilePaths: Set<String> = (fileEnumerator?
.allObjects
.compactMap { Attachment.localRelativeFilePath(from: ($0 as? URL)?.path) })
.defaulting(to: [])
.asSet()
// Note: Directories will have their own entries in the list, if there is a folder with content
// the file will include the directory in it's path with a forward slash so we can use this to
// distinguish empty directories from ones with content so we don't unintentionally delete a
// directory which contains content to keep as well as delete (directories which end up empty after
// this clean up will be removed during the next run)
let directoryNamesContainingContent: [String] = allAttachmentFilePaths
.filter { path -> Bool in path.contains("/") }
.compactMap { path -> String? in path.components(separatedBy: "/").first }
let orphanedAttachmentFiles: Set<String> = allAttachmentFilePaths
.subtracting(attachmentLocalRelativePaths)
.subtracting(directoryNamesContainingContent)
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 FileManager.default.removeItem(
atPath: URL(fileURLWithPath: Attachment.attachmentsFolder)
.appendingPathComponent(filepath)
.path
)
}
catch { deletionErrors.append(error) }
}
}
// Orphaned profile avatar files (actual deletion)
if details.typesToCollect.contains(.orphanedProfileAvatars) {
let allAvatarProfileFilenames: Set<String> = (try? FileManager.default
.contentsOfDirectory(atPath: ProfileManager.sharedDataProfileAvatarsDirPath))
.defaulting(to: [])
.asSet()
let orphanedAvatarFiles: Set<String> = allAvatarProfileFilenames
.subtracting(profileAvatarFilenames)
orphanedAvatarFiles.forEach { filename 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 FileManager.default.removeItem(
atPath: ProfileManager.profileAvatarFilepath(filename: filename)
)
}
catch { deletionErrors.append(error) }
}
}
// 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
}
success(job, false)
}
)
} }
} }
@ -34,12 +244,14 @@ public enum GarbageCollectionJob: JobExecutor {
extension GarbageCollectionJob { extension GarbageCollectionJob {
public enum Types: Codable, CaseIterable { public enum Types: Codable, CaseIterable {
case oldOpenGroupMessages
case expiredControlMessageProcessRecords case expiredControlMessageProcessRecords
case threadTypingIndicators case threadTypingIndicators
case oldOpenGroupMessages
case orphanedJobs
case orphanedLinkPreviews
case orphanedAttachments
case orphanedAttachmentFiles case orphanedAttachmentFiles
case orphanedProfileAvatars case orphanedProfileAvatars
case orphanedLinkPreviews
} }
public struct Details: Codable { public struct Details: Codable {

@ -82,7 +82,7 @@ public struct ProfileManager {
// MARK: - File Paths // MARK: - File Paths
private static let sharedDataProfileAvatarsDirPath: String = { public static let sharedDataProfileAvatarsDirPath: String = {
URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath()) URL(fileURLWithPath: OWSFileSystem.appSharedDataDirectoryPath())
.appendingPathComponent("ProfileAvatars") .appendingPathComponent("ProfileAvatars")
.path .path

@ -200,20 +200,11 @@ public final class GRDBStorage {
return keySpec return keySpec
} }
catch { catch {
print("RAWR \(error.localizedDescription), \((error as? KeychainStorageError)?.code), \(errSecItemNotFound)")
switch (error, (error as? KeychainStorageError)?.code) { switch (error, (error as? KeychainStorageError)?.code) {
// TODO: Are there other errors we know about that indicate an invalid keychain?
// errSecNotAvailable: OSStatus { get } /* No keychain is available. You may need to restart your computer. */
// public var errSecNoSuchKeychain
//errSecInteractionNotAllowed
case (StorageError.invalidKeySpec, _): case (StorageError.invalidKeySpec, _):
// For these cases it means either the keySpec or the keychain has become corrupt so in order to // For these cases it means either the keySpec or the keychain has become corrupt so in order to
// get back to a "known good state" and behave like a new install we need to reset the storage // get back to a "known good state" and behave like a new install we need to reset the storage
// and regenerate the key // and regenerate the key
// TODO: Check what this 'isRunningTests' does (use the approach to check if XCTTestCase exists instead?)
if !CurrentAppContext().isRunningTests { if !CurrentAppContext().isRunningTests {
// Try to reset app by deleting database. // Try to reset app by deleting database.
resetAllStorage() resetAllStorage()

@ -846,9 +846,10 @@ private final class JobQueue {
GRDBStorage.shared.write { db in GRDBStorage.shared.write { db in
guard guard
!permanentFailure && !permanentFailure && (
maxFailureCount >= 0 && maxFailureCount < 0 ||
job.failureCount + 1 < maxFailureCount job.failureCount + 1 < maxFailureCount
)
else { else {
SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")") SNLog("[JobRunner] \(queueContext) \(job.variant) failed permanently\(maxFailureCount >= 0 ? "; too many retries" : "")")

@ -205,7 +205,7 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate {
completion: { [weak self] _ in completion: { [weak self] _ in
self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } self?.stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
self?.stackView.frame = oldFrame self?.stackView.frame = oldFrame
self?.stackClippingView.isHidden = true self?.isHidden = true
self?.cellViews = [] self?.cellViews = []
} }
) )
@ -249,11 +249,11 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate {
self?.updateFocusedItem(focusedItem) self?.updateFocusedItem(focusedItem)
self?.stackView.layoutIfNeeded() self?.stackView.layoutIfNeeded()
self?.stackClippingView.isHidden = false self?.isHidden = false
updatedOldFrame = (self?.stackView.frame) updatedOldFrame = (self?.stackView.frame)
.defaulting(to: oldFrame) .defaulting(to: oldFrame)
self?.stackView.frame = oldFrame.offsetBy( self?.stackView.frame = updatedOldFrame.offsetBy(
dx: 0, dx: 0,
dy: oldFrame.height dy: oldFrame.height
) )
@ -324,6 +324,7 @@ public class GalleryRailView: UIView, GalleryRailCellViewDelegate {
selectedCellView?.setIsSelected(true) selectedCellView?.setIsSelected(true)
self.layoutIfNeeded() self.layoutIfNeeded()
self.stackView.layoutIfNeeded()
switch scrollFocusMode { switch scrollFocusMode {
case .keepCentered: case .keepCentered:

Loading…
Cancel
Save