diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m index aa0ef1b58d..91728e0937 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMessages.m @@ -3792,7 +3792,7 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); + (void)sendFakeMessages:(NSUInteger)counter thread:(TSThread *)thread isTextOnly:(BOOL)isTextOnly { - const NSUInteger kMaxBatchSize = 2500; + const NSUInteger kMaxBatchSize = 200; if (counter < kMaxBatchSize) { [self writeWithBlock:^(SDSAnyWriteTransaction *_Nonnull transaction) { [self sendFakeMessages:counter thread:thread isTextOnly:isTextOnly transaction:transaction]; @@ -3921,33 +3921,16 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); break; } case 3: { - NSString *filename = @"test.jpg"; - UInt32 filesize = 16; - - TSAttachmentStream *attachmentStream = [[TSAttachmentStream alloc] initWithContentType:@"audio/jpg" - byteCount:filesize - sourceFilename:filename - caption:nil - albumMessageId:nil]; - - NSError *error; - BOOL success = [attachmentStream writeData:[self createRandomNSDataOfSize:filesize] error:&error]; - OWSAssertDebug(success && !error); - [attachmentStream anyInsertWithTransaction:transaction]; - - [self createFakeOutgoingMessage:thread - messageBody:nil - attachmentId:attachmentStream.uniqueId - filename:filename - messageState:TSOutgoingMessageStateFailed - isDelivered:NO - isRead:NO - isVoiceMessage:NO - quotedMessage:nil - contactShare:nil - linkPreview:nil - messageSticker:nil - transaction:transaction]; + ConversationFactory *conversationFactory = [ConversationFactory new]; + // We want to produce a variety of album sizes, but favoring smaller albums + conversationFactory.attachmentCount = MAX(0, + MIN(SignalAttachment.maxAttachmentsAllowed, + ((NSInteger)((double)UINT32_MAX/(double)arc4random()) - 1))); + conversationFactory.threadCreator = ^(SDSAnyWriteTransaction *_transaction){ + return thread; + }; + + [conversationFactory createSentMessageWithTransaction:transaction]; break; } } @@ -4635,7 +4618,7 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); return [self createFakeOutgoingMessage:thread messageBody:messageBody - attachmentId:attachment.uniqueId + attachment:attachment filename:fakeAssetLoader.filename messageState:messageState isDelivered:isDelivered @@ -4650,7 +4633,7 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); + (TSOutgoingMessage *)createFakeOutgoingMessage:(TSThread *)thread messageBody:(nullable NSString *)messageBody - attachmentId:(nullable NSString *)attachmentId + attachment:(nullable TSAttachment *)attachment filename:(nullable NSString *)filename messageState:(TSOutgoingMessageState)messageState isDelivered:(BOOL)isDelivered @@ -4664,11 +4647,11 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); { OWSAssertDebug(thread); OWSAssertDebug(transaction); - OWSAssertDebug(messageBody.length > 0 || attachmentId.length > 0 || contactShare); + OWSAssertDebug(messageBody.length > 0 || attachment != nil || contactShare); NSMutableArray *attachmentIds = [NSMutableArray new]; - if (attachmentId) { - [attachmentIds addObject:attachmentId]; + if (attachment != nil) { + [attachmentIds addObject:attachment.uniqueId]; } // MJK TODO - remove senderTimestamp @@ -4689,6 +4672,11 @@ typedef OWSContact * (^OWSContactBlock)(SDSAnyWriteTransaction *transaction); [message anyInsertWithTransaction:transaction]; [message updateWithFakeMessageState:messageState transaction:transaction]; + [attachment anyUpdateWithTransaction:transaction block:^(TSAttachment *latest) { + // There's no public setter for albumMessageId, since it's usually set in the initializer. + // This isn't convenient for the DEBUG UI, so we abuse the migrateAlbumMessageId method. + [latest migrateAlbumMessageId:message.uniqueId]; + }]; if (isDelivered) { SignalServiceAddress *_Nullable address = thread.recipientAddresses.lastObject; OWSAssertDebug(address.isValid); diff --git a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift index b891d205f9..a4f4358518 100644 --- a/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift +++ b/Signal/src/ViewControllers/MediaGallery/MediaGallery.swift @@ -575,6 +575,7 @@ class MediaGallery { func ensureLoadedForMostRecentTileView() -> MediaGalleryItem? { guard let mostRecentItem: MediaGalleryItem = (databaseStorage.uiReadReturningResult { transaction in + // POST GRDB - fetch messages w/ attachments rather than separate query guard let attachment = self.mediaGalleryFinder.mostRecentMediaAttachment(transaction: transaction) else { return nil } diff --git a/SignalMessaging/utils/ThreadUtil.m b/SignalMessaging/utils/ThreadUtil.m index 163622992b..07f61c07f2 100644 --- a/SignalMessaging/utils/ThreadUtil.m +++ b/SignalMessaging/utils/ThreadUtil.m @@ -64,10 +64,6 @@ NS_ASSUME_NONNULL_BEGIN #pragma mark - -typedef void (^BuildOutgoingMessageCompletionBlock)(TSOutgoingMessage *savedMessage, - NSMutableArray *attachmentInfos, - SDSAnyWriteTransaction *writeTransaction); - @implementation ThreadUtil #pragma mark - Dependencies @@ -693,8 +689,12 @@ typedef void (^BuildOutgoingMessageCompletionBlock)(TSOutgoingMessage *savedMess [TSInteraction anyRemoveAllWithInstantationWithTransaction:transaction]; [TSAttachment anyRemoveAllWithInstantationWithTransaction:transaction]; [SignalRecipient anyRemoveAllWithInstantationWithTransaction:transaction]; + + // Deleting attachments above should be enough to remove any gallery items, but + // we redunantly clean up *all* gallery items to be safe. + [AnyMediaGalleryFinder didRemoveAllContentWithTransaction:transaction]; }]; - [TSAttachmentStream deleteAttachments]; + [TSAttachmentStream deleteAttachmentsFromDisk]; } #pragma mark - Find Content diff --git a/SignalServiceKit/Resources/schema.sql b/SignalServiceKit/Resources/schema.sql index 96f2701d2b..4818cf83e8 100644 --- a/SignalServiceKit/Resources/schema.sql +++ b/SignalServiceKit/Resources/schema.sql @@ -711,3 +711,27 @@ CREATE ON "model_SignalAccount"("recipientUUID" ) ; + +CREATE + TABLE + IF NOT EXISTS "media_gallery_items" ( + "attachmentId" INTEGER NOT NULL UNIQUE + ,"albumMessageId" INTEGER NOT NULL + ,"threadId" INTEGER NOT NULL + ,"originalAlbumOrder" INTEGER NOT NULL + ) +; + +CREATE + INDEX "index_media_gallery_items_for_gallery" + ON "media_gallery_items"("threadId" + ,"albumMessageId" + ,"originalAlbumOrder" +) +; + +CREATE + INDEX "index_media_gallery_items_on_attachmentId" + ON "media_gallery_items"("attachmentId" +) +; diff --git a/SignalServiceKit/src/Messages/Attachments/OWSAttachmentDownloads.m b/SignalServiceKit/src/Messages/Attachments/OWSAttachmentDownloads.m index fdab6b85b3..ceef4f7449 100644 --- a/SignalServiceKit/src/Messages/Attachments/OWSAttachmentDownloads.m +++ b/SignalServiceKit/src/Messages/Attachments/OWSAttachmentDownloads.m @@ -374,8 +374,8 @@ typedef void (^AttachmentDownloadFailure)(NSError *error); if (![existingAttachment isKindOfClass:[TSAttachmentPointer class]]) { OWSFailDebug(@"Unexpected attachment pointer class: %@", existingAttachment.class); } - // This will clobber the attachmentPointer - [attachmentStream anyOverwritingUpdateWithTransaction:transaction]; + [attachmentPointer anyRemoveWithTransaction:transaction]; + [attachmentStream anyInsertWithTransaction:transaction]; if (job.message != nil) { [self reloadAndTouchLatestVersionOfMessage:job.message transaction:transaction]; diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h index b42f18e5ff..3a3e25ccb7 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.h @@ -120,7 +120,7 @@ NS_SWIFT_NAME(init(grdbId:uniqueId:albumMessageId:attachmentType:blurHash:byteCo - (BOOL)writeConsumingDataSource:(id)dataSource error:(NSError **)error NS_SWIFT_NAME(writeConsumingDataSource(_:)); -+ (void)deleteAttachments; ++ (void)deleteAttachmentsFromDisk; + (NSString *)attachmentsFolder; + (NSString *)legacyAttachmentsDirPath; diff --git a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m index 983e1f252a..42cf9e0b77 100644 --- a/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m +++ b/SignalServiceKit/src/Messages/Attachments/TSAttachmentStream.m @@ -458,11 +458,18 @@ typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail); } } +- (void)anyDidInsertWithTransaction:(SDSAnyWriteTransaction *)transaction +{ + [super anyDidInsertWithTransaction:transaction]; + [AnyMediaGalleryFinder didInsertAttachmentStream:self transaction:transaction]; +} + - (void)anyDidRemoveWithTransaction:(SDSAnyWriteTransaction *)transaction { [super anyDidRemoveWithTransaction:transaction]; [self removeFile]; + [AnyMediaGalleryFinder didRemoveAttachmentStream:self transaction:transaction]; } - (BOOL)isValidVisualMedia @@ -607,7 +614,7 @@ typedef void (^OWSLoadedThumbnailSuccess)(OWSLoadedThumbnail *loadedThumbnail); return image; } -+ (void)deleteAttachments ++ (void)deleteAttachmentsFromDisk { NSError *error; NSFileManager *fileManager = [NSFileManager defaultManager]; diff --git a/SignalServiceKit/src/Storage/Database/GRDBDatabaseStorageAdapter.swift b/SignalServiceKit/src/Storage/Database/GRDBDatabaseStorageAdapter.swift index 0ca0c6edb9..4603c3af4a 100644 --- a/SignalServiceKit/src/Storage/Database/GRDBDatabaseStorageAdapter.swift +++ b/SignalServiceKit/src/Storage/Database/GRDBDatabaseStorageAdapter.swift @@ -247,7 +247,7 @@ public class GRDBDatabaseStorageAdapter: NSObject { deleteDBKeys() if (CurrentAppContext().isMainApp) { - TSAttachmentStream.deleteAttachments() + TSAttachmentStream.deleteAttachmentsFromDisk() } // TODO: Delete Profiles on Disk? diff --git a/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift b/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift index 1ca83b8300..a22fed59d8 100644 --- a/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift +++ b/SignalServiceKit/src/Storage/Database/GRDBSchemaMigrator.swift @@ -46,14 +46,14 @@ public class GRDBSchemaMigrator: NSObject { case signalAccount_add_contactAvatars case signalAccount_add_contactAvatars_indices case jobRecords_add_attachmentId - + case createMediaGalleryItems // NOTE: Every time we add a migration id, consider // incrementing grdbSchemaVersionLatest. // We only need to do this for breaking changes. } public static let grdbSchemaVersionDefault: UInt = 0 - public static let grdbSchemaVersionLatest: UInt = 1 + public static let grdbSchemaVersionLatest: UInt = 2 // An optimization for new users, we have the first migration import the latest schema // and mark any other migrations as "already run". @@ -109,6 +109,7 @@ public class GRDBSchemaMigrator: NSObject { """ try database.execute(sql: sql) } + migrator.registerMigration(MigrationId.signalAccount_add_contactAvatars_indices.rawValue) { db in let sql = """ CREATE @@ -138,6 +139,30 @@ public class GRDBSchemaMigrator: NSObject { } } + migrator.registerMigration(MigrationId.createMediaGalleryItems.rawValue) { db in + try db.create(table: "media_gallery_items") { table in + table.column("attachmentId", .integer) + .notNull() + .unique() + table.column("albumMessageId", .integer) + .notNull() + table.column("threadId", .integer) + .notNull() + table.column("originalAlbumOrder", .integer) + .notNull() + } + + try db.create(index: "index_media_gallery_items_for_gallery", + on: "media_gallery_items", + columns: ["threadId", "albumMessageId", "originalAlbumOrder"]) + + try db.create(index: "index_media_gallery_items_on_attachmentId", + on: "media_gallery_items", + columns: ["attachmentId"]) + + try createInitialGalleryRecords(transaction: GRDBWriteTransaction(database: db)) + } + return migrator }() } @@ -774,3 +799,31 @@ private func createV1Schema(db: Database) throws { try GRDBFullTextSearchFinder.createTables(database: db) } + +func createInitialGalleryRecords(transaction: GRDBWriteTransaction) throws { + try Bench(title: "createInitialGalleryRecords", logInProduction: true) { + let scope = AttachmentRecord.filter(sql: "\(attachmentColumn: .recordType) = \(SDSRecordType.attachmentStream.rawValue)") + + let totalCount = try scope.fetchCount(transaction.database) + let cursor = try scope.fetchCursor(transaction.database) + var i = 0 + try Batching.loop(batchSize: 500) { stopPtr in + guard let record = try cursor.next() else { + stopPtr.pointee = true + return + } + + i+=1 + if (i % 100) == 0 { + Logger.info("migrated \(i) / \(totalCount)") + } + + guard let attachmentStream = try TSAttachment.fromRecord(record) as? TSAttachmentStream else { + owsFailDebug("unexpected record: \(record.recordType)") + return + } + + try GRDBMediaGalleryFinder.insertGalleryRecord(attachmentStream: attachmentStream, transaction: transaction) + } + } +} diff --git a/SignalServiceKit/src/Storage/Database/Snapshots/UIDatabaseSnapshot.swift b/SignalServiceKit/src/Storage/Database/Snapshots/UIDatabaseSnapshot.swift index 28e8252ebe..e7af68cd8e 100644 --- a/SignalServiceKit/src/Storage/Database/Snapshots/UIDatabaseSnapshot.swift +++ b/SignalServiceKit/src/Storage/Database/Snapshots/UIDatabaseSnapshot.swift @@ -40,6 +40,8 @@ public class UIDatabaseObserver: NSObject { public static let kMaxIncrementalRowChanges = 200 + private lazy var nonModelTables: Set = Set([MediaGalleryRecord.databaseTableName]) + // tldr; Instead, of protecting UIDatabaseObserver state with a nested DispatchQueue, // which would break GRDB's SchedulingWatchDog, we use objc_sync // @@ -116,6 +118,11 @@ extension UIDatabaseObserver: TransactionObserver { return false } + guard !nonModelTables.contains(eventKind.tableName) else { + // Ignore updates to non-model tables + return false + } + return true } diff --git a/SignalServiceKit/src/Storage/MediaGalleryFinder.swift b/SignalServiceKit/src/Storage/MediaGalleryFinder.swift index e66e7b03f4..97fbadf080 100644 --- a/SignalServiceKit/src/Storage/MediaGalleryFinder.swift +++ b/SignalServiceKit/src/Storage/MediaGalleryFinder.swift @@ -14,7 +14,8 @@ protocol MediaGalleryFinder { func enumerateMediaAttachments(range: NSRange, transaction: ReadTransaction, block: @escaping (TSAttachment) -> Void) } -public class AnyMediaGalleryFinder { +@objc +public class AnyMediaGalleryFinder: NSObject { public typealias ReadTransaction = SDSAnyReadTransaction public lazy var yapAdapter = { @@ -70,6 +71,48 @@ extension AnyMediaGalleryFinder: MediaGalleryFinder { return yapAdapter.mostRecentMediaAttachment(transaction: yapRead) } } + + @objc(didInsertAttachmentStream:transaction:) + public class func didInsert(attachmentStream: TSAttachmentStream, transaction: SDSAnyWriteTransaction) { + switch transaction.writeTransaction { + case .yapWrite: + break + case .grdbWrite(let grdbWrite): + do { + try GRDBMediaGalleryFinder.insertGalleryRecord(attachmentStream: attachmentStream, transaction: grdbWrite) + } catch { + owsFailDebug("error: \(error)") + } + } + } + + @objc(didRemoveAttachmentStream:transaction:) + public class func didRemove(attachmentStream: TSAttachmentStream, transaction: SDSAnyWriteTransaction) { + switch transaction.writeTransaction { + case .yapWrite: + break + case .grdbWrite(let grdbWrite): + do { + try GRDBMediaGalleryFinder.removeAnyGalleryRecord(attachmentStream: attachmentStream, transaction: grdbWrite) + } catch { + owsFailDebug("error: \(error)") + } + } + } + + @objc + public class func didRemoveAllContent(transaction: SDSAnyWriteTransaction) { + switch transaction.writeTransaction { + case .yapWrite: + break + case .grdbWrite(let grdbWrite): + do { + try GRDBMediaGalleryFinder.removeAllGalleryRecords(transaction: grdbWrite) + } catch { + owsFailDebug("error: \(error)") + } + } + } } // MARK: - GRDB @@ -84,8 +127,12 @@ public class GRDBMediaGalleryFinder: NSObject { // MARK: - - var threadUniqueId: String { - return thread.uniqueId + var threadId: Int64 { + guard let rowId = thread.grdbId else { + owsFailDebug("thread.grdbId was unexpectedly nil") + return 0 + } + return rowId.int64Value } public static func setup(storage: GRDBDatabaseStorageAdapter) { @@ -99,6 +146,76 @@ public class GRDBMediaGalleryFinder: NSObject { return MIMETypeUtil.isVisualMedia(contentType) } + + public class func removeAnyGalleryRecord(attachmentStream: TSAttachmentStream, transaction: GRDBWriteTransaction) throws { + let sql = """ + DELETE FROM \(MediaGalleryRecord.databaseTableName) WHERE attachmentId = ? + """ + guard let attachmentId = attachmentStream.grdbId else { + owsFailDebug("attachmentId was unexpectedly nil") + return + } + + guard attachmentStream.albumMessageId != nil else { + Logger.verbose("not a gallery attachment") + return + } + + try transaction.database.execute(sql: sql, arguments: [attachmentId.int64Value]) + } + + public class func insertGalleryRecord(attachmentStream: TSAttachmentStream, transaction: GRDBWriteTransaction) throws { + guard let attachmentRowId = attachmentStream.grdbId else { + owsFailDebug("attachmentRowId was unexpectedly nil") + return + } + + guard let messageUniqueId = attachmentStream.albumMessageId else { + Logger.verbose("not a gallery attachment") + return + } + + guard let message = TSMessage.anyFetchMessage(uniqueId: messageUniqueId, transaction: transaction.asAnyRead) else { + owsFailDebug("message was unexpectedly nil") + return + } + + guard let messageRowId = message.grdbId else { + owsFailDebug("message was unexpectedly nil") + return + } + + let thread = message.thread(transaction: transaction.asAnyRead) + guard let threadId = thread.grdbId else { + owsFailDebug("threadId was unexpectedly nil") + return + } + + guard let originalAlbumIndex = message.attachmentIds.firstIndex(of: attachmentStream.uniqueId) else { + owsFailDebug("originalAlbumIndex was unexpectedly nil") + return + } + + let galleryRecord = MediaGalleryRecord(attachmentId: attachmentRowId.int64Value, + albumMessageId: messageRowId.int64Value, + threadId: threadId.int64Value, + originalAlbumOrder: originalAlbumIndex) + + try galleryRecord.insert(transaction.database) + } + + public class func removeAllGalleryRecords(transaction: GRDBWriteTransaction) throws { + try MediaGalleryRecord.deleteAll(transaction.database) + } +} + +struct MediaGalleryRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "media_gallery_items" + + let attachmentId: Int64 + let albumMessageId: Int64 + let threadId: Int64 + let originalAlbumOrder: Int } extension GRDBMediaGalleryFinder: MediaGalleryFinder { @@ -107,62 +224,59 @@ extension GRDBMediaGalleryFinder: MediaGalleryFinder { func mostRecentMediaAttachment(transaction: GRDBReadTransaction) -> TSAttachment? { let sql = """ SELECT \(AttachmentRecord.databaseTableName).* - FROM \(AttachmentRecord.databaseTableName) - INNER JOIN \(InteractionRecord.databaseTableName) - ON \(attachmentColumn: .albumMessageId) = \(interactionColumnFullyQualified: .uniqueId) - AND \(interactionColumn: .threadUniqueId) = ? - AND \(interactionColumn: .isViewOnceMessage) = FALSE - WHERE \(attachmentColumnFullyQualified: .recordType) = \(SDSRecordType.attachmentStream.rawValue) - AND \(attachmentColumn: .albumMessageId) IS NOT NULL + FROM "media_gallery_items" + INNER JOIN \(AttachmentRecord.databaseTableName) + ON media_gallery_items.attachmentId = model_TSAttachment.id AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE + INNER JOIN \(InteractionRecord.databaseTableName) + ON media_gallery_items.albumMessageId = \(interactionColumnFullyQualified: .id) + AND \(interactionColumn: .isViewOnceMessage) = FALSE + WHERE media_gallery_items.threadId = ? ORDER BY - \(interactionColumnFullyQualified: .id) DESC, - \(attachmentColumnFullyQualified: .id) DESC + media_gallery_items.albumMessageId DESC, + media_gallery_items.originalAlbumOrder DESC LIMIT 1 """ - // GRDB TODO: migrate such that attachment.id reflects ordering in TSInteraction.attachmentIds - let cursor = TSAttachment.grdbFetchCursor(sql: sql, arguments: [threadUniqueId], transaction: transaction) + let cursor = TSAttachment.grdbFetchCursor(sql: sql, arguments: [threadId], transaction: transaction) return try! cursor.next() } func mediaCount(transaction: GRDBReadTransaction) -> UInt { let sql = """ - SELECT - COUNT(*) - FROM \(AttachmentRecord.databaseTableName) - INNER JOIN \(InteractionRecord.databaseTableName) - ON \(attachmentColumn: .albumMessageId) = \(interactionColumnFullyQualified: .uniqueId) - AND \(interactionColumn: .threadUniqueId) = ? - AND \(interactionColumn: .isViewOnceMessage) = FALSE - WHERE \(attachmentColumnFullyQualified: .recordType) = \(SDSRecordType.attachmentStream.rawValue) - AND \(attachmentColumn: .albumMessageId) IS NOT NULL - AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE + SELECT COUNT(*) + FROM "media_gallery_items" + INNER JOIN \(AttachmentRecord.databaseTableName) + ON media_gallery_items.attachmentId = model_TSAttachment.id + AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE + INNER JOIN \(InteractionRecord.databaseTableName) + ON media_gallery_items.albumMessageId = \(interactionColumnFullyQualified: .id) + AND \(interactionColumn: .isViewOnceMessage) = FALSE + WHERE media_gallery_items.threadId = ? """ - return try! UInt.fetchOne(transaction.database, sql: sql, arguments: [threadUniqueId]) ?? 0 + return try! UInt.fetchOne(transaction.database, sql: sql, arguments: [threadId]) ?? 0 } func enumerateMediaAttachments(range: NSRange, transaction: GRDBReadTransaction, block: @escaping (TSAttachment) -> Void) { let sql = """ - SELECT \(AttachmentRecord.databaseTableName).* - FROM \(AttachmentRecord.databaseTableName) - INNER JOIN \(InteractionRecord.databaseTableName) - ON \(attachmentColumn: .albumMessageId) = \(interactionColumnFullyQualified: .uniqueId) - AND \(interactionColumn: .threadUniqueId) = ? - AND \(interactionColumn: .isViewOnceMessage) = FALSE - WHERE \(attachmentColumnFullyQualified: .recordType) = \(SDSRecordType.attachmentStream.rawValue) - AND \(attachmentColumn: .albumMessageId) IS NOT NULL - AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE - ORDER BY - \(interactionColumnFullyQualified: .id), - \(attachmentColumnFullyQualified: .id) - LIMIT \(range.length) - OFFSET \(range.lowerBound) + SELECT \(AttachmentRecord.databaseTableName).* + FROM "media_gallery_items" + INNER JOIN \(AttachmentRecord.databaseTableName) + ON media_gallery_items.attachmentId = model_TSAttachment.id + AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE + INNER JOIN \(InteractionRecord.databaseTableName) + ON media_gallery_items.albumMessageId = \(interactionColumnFullyQualified: .id) + AND \(interactionColumn: .isViewOnceMessage) = FALSE + WHERE media_gallery_items.threadId = ? + ORDER BY + media_gallery_items.albumMessageId, + media_gallery_items.originalAlbumOrder + LIMIT \(range.length) + OFFSET \(range.lowerBound) """ - // GRDB TODO: migrate such that attachment.id reflects ordering in TSInteraction.attachmentIds - let cursor = TSAttachment.grdbFetchCursor(sql: sql, arguments: [threadUniqueId], transaction: transaction) + let cursor = TSAttachment.grdbFetchCursor(sql: sql, arguments: [threadId], transaction: transaction) while let next = try! cursor.next() { block(next) } @@ -175,22 +289,27 @@ extension GRDBMediaGalleryFinder: MediaGalleryFinder { SELECT ROW_NUMBER() OVER ( ORDER BY - \(interactionColumnFullyQualified: .id), - \(attachmentColumnFullyQualified: .id) + media_gallery_items.albumMessageId, + media_gallery_items.originalAlbumOrder ) - 1 as mediaIndex, - \(attachmentColumnFullyQualified: .uniqueId) - FROM \(AttachmentRecord.databaseTableName) + media_gallery_items.attachmentId + FROM media_gallery_items + INNER JOIN \(AttachmentRecord.databaseTableName) + ON media_gallery_items.attachmentId = model_TSAttachment.id + AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE INNER JOIN \(InteractionRecord.databaseTableName) - ON \(attachmentColumn: .albumMessageId) = \(interactionColumnFullyQualified: .uniqueId) - AND \(interactionColumn: .threadUniqueId) = ? + ON media_gallery_items.albumMessageId = \(interactionColumnFullyQualified: .id) AND \(interactionColumn: .isViewOnceMessage) = FALSE - WHERE \(attachmentColumnFullyQualified: .recordType) = \(SDSRecordType.attachmentStream.rawValue) - AND \(attachmentColumn: .albumMessageId) IS NOT NULL - AND IsVisualMediaContentType(\(attachmentColumn: .contentType)) IS TRUE + WHERE media_gallery_items.threadId = ? ) - WHERE \(attachmentColumn: .uniqueId) = ? + WHERE attachmentId = ? """ - return try! Int.fetchOne(transaction.database, sql: sql, arguments: [threadUniqueId, attachment.uniqueId]) + guard let attachmentRowId = attachment.grdbId else { + owsFailDebug("attachment.grdbId was unexpectedly nil") + return nil + } + + return try! Int.fetchOne(transaction.database, sql: sql, arguments: [threadId, attachmentRowId]) } } diff --git a/SignalServiceKit/src/Storage/OWSStorage.m b/SignalServiceKit/src/Storage/OWSStorage.m index b2072ef281..584bcc9d28 100644 --- a/SignalServiceKit/src/Storage/OWSStorage.m +++ b/SignalServiceKit/src/Storage/OWSStorage.m @@ -805,7 +805,7 @@ NSString *const kNSUserDefaults_DatabaseExtensionVersionMap = @"kNSUserDefaults_ [self deleteDBKeys]; if (CurrentAppContext().isMainApp) { - [TSAttachmentStream deleteAttachments]; + [TSAttachmentStream deleteAttachmentsFromDisk]; } // TODO: Delete Profiles on Disk? diff --git a/SignalServiceKit/src/TestUtils/Factories.swift b/SignalServiceKit/src/TestUtils/Factories.swift index 7016a5b32d..dedecc424f 100644 --- a/SignalServiceKit/src/TestUtils/Factories.swift +++ b/SignalServiceKit/src/TestUtils/Factories.swift @@ -426,6 +426,62 @@ public class GroupThreadFactory: NSObject, Factory { } } +@objc +public class ConversationFactory: NSObject { + + var databaseStorage: SDSDatabaseStorage { + return SDSDatabaseStorage.shared + } + + @objc + public var attachmentCount: Int = 0 + + @objc + @discardableResult + public func createSentMessage(transaction: SDSAnyWriteTransaction) -> TSOutgoingMessage { + let outgoingFactory = OutgoingMessageFactory() + outgoingFactory.threadCreator = threadCreator + let message = outgoingFactory.create(transaction: transaction) + + let attachmentInfos: [OutgoingAttachmentInfo] = (0.. TSThread = { transaction in + ContactThreadFactory().create(transaction: transaction) + } + + @objc + public var attachmentInfoBuilder: (TSOutgoingMessage, String?) -> OutgoingAttachmentInfo = { outgoingMessage, caption in + let dataSource = DataSourceValue.dataSource(with: ImageFactory().buildPNGData(), fileExtension: "png")! + return OutgoingAttachmentInfo(dataSource: dataSource, + contentType: "image/png", + sourceFilename: nil, + caption: caption, + albumMessageId: outgoingMessage.uniqueId) + } + +} + @objc public class AttachmentStreamFactory: NSObject, Factory { @@ -1707,22 +1763,100 @@ public class CommonGenerator: NSObject { return result.joined(separator: " ") } + @objc static public var sentence: String { return sentences.randomElement()! } + @objc static public func sentences(count: UInt) -> [String] { return (0.. String { return sentences(count: sentenceCount).joined(separator: " ") } } +@objc +public class ImageFactory: NSObject { + + @objc + public func build() -> UIImage { + return type(of: self).buildImage(size: sizeBuilder(), + backgroundColor: backgroundColorBuilder(), + textColor: textColorBuilder(), + text: textBuilder()) + } + + @objc + public func buildPNGData() -> Data { + guard let data = build().pngData() else { + owsFailDebug("data was unexpectedly nil") + return Data() + } + return data + } + + @objc + public func buildJPGData() -> Data { + guard let data = build().jpegData(compressionQuality: 0.9) else { + owsFailDebug("data was unexpectedly nil") + return Data() + } + return data + } + + public var sizeBuilder: () -> CGSize = { return CGSize(width: (50..<1000).randomElement()!, height: (50..<1000).randomElement()!) } + public var backgroundColorBuilder: () -> UIColor = { return [UIColor.purple, UIColor.yellow, UIColor.green, UIColor.blue, UIColor.red, UIColor.orange].randomElement()! } + public var textColorBuilder: () -> UIColor = { return [UIColor.black, UIColor.white].randomElement()! } + public var textBuilder: () -> String = { return "\(CommonGenerator.word)\n\(CommonGenerator.word)" } + + public class func buildImage(size: CGSize, backgroundColor: UIColor, textColor: UIColor, text: String) -> UIImage { + return autoreleasepool { + let imageSize = CGSize(width: size.width / UIScreen.main.scale, + height: size.height / UIScreen.main.scale) + + let imageFrame = CGRect(origin: .zero, size: imageSize) + let font = UIFont.boldSystemFont(ofSize: imageSize.width * 0.1) + + let textAttributes: [NSAttributedString.Key: Any] = [.font: font, + .foregroundColor: textColor] + + let textFrame = text.boundingRect(with: imageFrame.size, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: textAttributes, + context: nil) + + UIGraphicsBeginImageContextWithOptions(imageFrame.size, false, UIScreen.main.scale) + guard let context = UIGraphicsGetCurrentContext() else { + owsFailDebug("context was unexpectedly nil") + return UIImage() + } + + context.setFillColor(backgroundColor.cgColor) + context.fill(imageFrame) + + text.draw(at: CGPoint(x: imageFrame.midX - textFrame.midX, + y: imageFrame.midY - textFrame.midY), + withAttributes: textAttributes) + + guard let image = UIGraphicsGetImageFromCurrentImageContext() else { + owsFailDebug("image was unexpectedly nil") + return UIImage() + } + UIGraphicsEndImageContext() + + return image + } + } +} + #endif diff --git a/SignalServiceKit/src/Util/OWSFileSystem.m b/SignalServiceKit/src/Util/OWSFileSystem.m index bc88f56518..c6dd660d48 100644 --- a/SignalServiceKit/src/Util/OWSFileSystem.m +++ b/SignalServiceKit/src/Util/OWSFileSystem.m @@ -119,9 +119,14 @@ NS_ASSUME_NONNULL_BEGIN + (NSString *)cachesDirectoryPath { - NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); - OWSAssertDebug(paths.count >= 1); - return paths[0]; + static NSString *result; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + OWSAssert(paths.count >= 1); + result = paths[0]; + }); + return result; } + (nullable NSError *)renameFilePathUsingRandomExtension:(NSString *)oldFilePath