Drop TSAttachment migration

Co-authored-by: sashaweiss-signal <sasha@signal.org>
Co-authored-by: Max Radermacher <max@signal.org>
This commit is contained in:
Harry 2025-12-11 12:00:52 -08:00 committed by GitHub
parent 8c3fbbdca3
commit 2eba0c6360
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 29 additions and 5968 deletions

View File

@ -984,7 +984,6 @@
665C0D5E2ADF53E200539A37 /* BackupArchiveManagerImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665C0D5D2ADF53E200539A37 /* BackupArchiveManagerImpl.swift */; };
665C0D602ADF57D000539A37 /* BackupArchive+Shims.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665C0D5F2ADF57D000539A37 /* BackupArchive+Shims.swift */; };
665C0D622AE0552900539A37 /* BackupArchiveProtoStreamProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665C0D612AE0552900539A37 /* BackupArchiveProtoStreamProvider.swift */; };
665C758C2C35A55300D2E4BA /* TSAttachmentMigration+ThreadWallpaper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665C758B2C35A55300D2E4BA /* TSAttachmentMigration+ThreadWallpaper.swift */; };
665CBD052BADC87A0059EA4F /* DraftQuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665CBD042BADC87A0059EA4F /* DraftQuotedReplyModel.swift */; };
665D9B452C111C6D00E73E94 /* AttachmentMultisend+OversizeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665D9B442C111C6D00E73E94 /* AttachmentMultisend+OversizeText.swift */; };
665EF86D290C385B00F490D2 /* OWSNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665EF86C290C385B00F490D2 /* OWSNavigationController.swift */; };
@ -992,7 +991,6 @@
665FAE8C2A02C0D400FA298D /* SpoilerRevealState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665FAE8B2A02C0D400FA298D /* SpoilerRevealState.swift */; };
6660725E2BAB36960084B3D2 /* AttachmentDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6660725D2BAB36960084B3D2 /* AttachmentDataSource.swift */; };
666072622BAB58A20084B3D2 /* OWSContactSerializationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666072602BAB58850084B3D2 /* OWSContactSerializationTest.swift */; };
6660C7972C45C34A00D9C30A /* TSAttachmentMigration+TSMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6660C7962C45C34A00D9C30A /* TSAttachmentMigration+TSMessage.swift */; };
6664B9AB2A314EBD008EF74B /* SpoilerRevealStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6664B9AA2A314EBD008EF74B /* SpoilerRevealStateTests.swift */; };
666654212AD0B03F00B23B32 /* MasterKeySyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 666654202AD0B03F00B23B32 /* MasterKeySyncManager.swift */; };
66681CDF2C58174F00E50136 /* BackupAttachmentDownloadStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66681CDE2C58174F00E50136 /* BackupAttachmentDownloadStoreTests.swift */; };
@ -1085,13 +1083,6 @@
6691E7EF2996E8FB0032A68A /* TSRequestOWSURLSessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691E7EE2996E8FB0032A68A /* TSRequestOWSURLSessionMock.swift */; };
6691E7F22996E9BC0032A68A /* RegistrationSessionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691E7F12996E9BC0032A68A /* RegistrationSessionManagerMock.swift */; };
6691E7F72996EAD70032A68A /* SecureValueRecoveryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691E7F62996EAD70032A68A /* SecureValueRecoveryMock.swift */; };
669379ED2C3C5B2C00EED7A0 /* TSAttachmentMigration+Records.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379EC2C3C5B2C00EED7A0 /* TSAttachmentMigration+Records.swift */; };
669379EF2C3C5E5800EED7A0 /* TSAttachmentMigration+AudioWaveformManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379EE2C3C5E5800EED7A0 /* TSAttachmentMigration+AudioWaveformManager.swift */; };
669379F12C3C79E800EED7A0 /* TSAttachmentMigration+OWSMediaUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379F02C3C79E800EED7A0 /* TSAttachmentMigration+OWSMediaUtils.swift */; };
669379F32C3C7C3B00EED7A0 /* TSAttachmentMigration+OWSImageSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379F22C3C7C3B00EED7A0 /* TSAttachmentMigration+OWSImageSource.swift */; };
669379F52C3C7EA800EED7A0 /* TSAttachmentMigration+ImageMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379F42C3C7EA800EED7A0 /* TSAttachmentMigration+ImageMetadata.swift */; };
669379F72C3C847000EED7A0 /* TSAttachmentMigration+AttachmentValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669379F62C3C847000EED7A0 /* TSAttachmentMigration+AttachmentValidator.swift */; };
66937A032C3F4EFC00EED7A0 /* TSAttachmentMigration+StoryMessageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66937A022C3F4EFC00EED7A0 /* TSAttachmentMigration+StoryMessageAttachment.swift */; };
6694BAB32CE5792B0015633F /* BackupArchiveProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6694BAB22CE579270015633F /* BackupArchiveProgress.swift */; };
6694BF682B36484900B18764 /* PinnedThreadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6694BF672B36484800B18764 /* PinnedThreadManager.swift */; };
6694BF6A2B3650E400B18764 /* PinnedThreadStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6694BF692B3650E400B18764 /* PinnedThreadStore.swift */; };
@ -1118,7 +1109,6 @@
669E900728B43F5B00043D28 /* SystemStoryManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669E900628B43F5B00043D28 /* SystemStoryManagerProtocol.swift */; };
669E901028B57D6300043D28 /* SystemStoryManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669E900F28B57D6300043D28 /* SystemStoryManagerMock.swift */; };
669FAE1B2B7AC919009EE2FE /* OWSLinkPreviewSerializationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669FAE1A2B7AC919009EE2FE /* OWSLinkPreviewSerializationTest.swift */; };
66A1ABE22C3311B40033C5EB /* TSAttachmentMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A1ABE12C3311B40033C5EB /* TSAttachmentMigration.swift */; };
66A1DF73298C635E00C4E4A7 /* RegistrationRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A1DF72298C635E00C4E4A7 /* RegistrationRequestFactory.swift */; };
66A1DF75298C73D900C4E4A7 /* RegistrationServiceResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A1DF74298C73D900C4E4A7 /* RegistrationServiceResponses.swift */; };
66A1F4E22E035C020095DE4B /* BackupExportJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A1F4E12E035BFE0095DE4B /* BackupExportJob.swift */; };
@ -1143,7 +1133,6 @@
66B152AC2DD6FDA700DE25CC /* AttachmentOffloadingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B152AB2DD6FD9400DE25CC /* AttachmentOffloadingManager.swift */; };
66B1E26C2CB187B3005F43AC /* AttachmentUploadStoreImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B1E26B2CB187A0005F43AC /* AttachmentUploadStoreImpl.swift */; };
66B1E2702CB48C53005F43AC /* Array+SSKTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B1E26F2CB48C48005F43AC /* Array+SSKTest.swift */; };
66B2FBFE2D10F5EB00189908 /* IncrementalMessageTSAttachmentMigratorFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B2FBFD2D10F5DE00189908 /* IncrementalMessageTSAttachmentMigratorFactory.swift */; };
66B5451A2DD5B9A00016289B /* BackupListMediaManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B545192DD5B9980016289B /* BackupListMediaManager.swift */; };
66B78E032BE59B860022580E /* StickerMetadata+TSResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B78E022BE59B860022580E /* StickerMetadata+TSResource.swift */; };
66B78E062BE5AADF0022580E /* AttachmentViewOnceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B78E052BE5AADF0022580E /* AttachmentViewOnceManager.swift */; };
@ -1164,9 +1153,6 @@
66C1A8802BB77EA50076C65A /* AttachmentUploadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1A87E2BB77E950076C65A /* AttachmentUploadManagerTests.swift */; };
66C1A8852BB77EE00076C65A /* AttachmentUploadManagerTestHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1A8812BB77EBB0076C65A /* AttachmentUploadManagerTestHelper.swift */; };
66C1A8862BB77EE30076C65A /* AttachmentUploadManagerTestMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1A8832BB77EC60076C65A /* AttachmentUploadManagerTestMocks.swift */; };
66C1BF512D0CC7C9002296F7 /* IncrementalTSAttachmentMigrationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1BF502D0CC7C7002296F7 /* IncrementalTSAttachmentMigrationStore.swift */; };
66C1BF532D0CC7EB002296F7 /* IncrementalMessageTSAttachmentMigrator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1BF522D0CC7DB002296F7 /* IncrementalMessageTSAttachmentMigrator.swift */; };
66C1BF552D0CC88A002296F7 /* IncrementalMessageTSAttachmentMigrationRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C1BF542D0CC881002296F7 /* IncrementalMessageTSAttachmentMigrationRunner.swift */; };
66C2B1312A05D28A008DDE72 /* TSRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C2B1302A05D28A008DDE72 /* TSRequest.swift */; };
66C2B1362A0DB02E008DDE72 /* SVRUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C2B1352A0DB02E008DDE72 /* SVRUtil.swift */; };
66C2B1382A0DB6A9008DDE72 /* SVRAuthCredential.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C2B1372A0DB6A9008DDE72 /* SVRAuthCredential.swift */; };
@ -1263,8 +1249,6 @@
66F98DE62DBBED6C009F1A86 /* LineWrappingStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F98DE52DBBED68009F1A86 /* LineWrappingStackView.swift */; };
66F98DE82DBBF25A009F1A86 /* LineWrappingStackViewTestController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F98DE72DBBF24F009F1A86 /* LineWrappingStackViewTestController.swift */; };
66F98DEA2DC53155009F1A86 /* ChatListViewController+BackupDownloadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66F98DE92DC5314E009F1A86 /* ChatListViewController+BackupDownloadProgressView.swift */; };
66FA12B42E9971E300A1F3C2 /* AVAssetReaderTrackOutputWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 66FA12B32E9971E300A1F3C2 /* AVAssetReaderTrackOutputWrapper.h */; settings = {ATTRIBUTES = (Public, ); }; };
66FA12B62E99727300A1F3C2 /* AVAssetReaderTrackOutputWrapper.m in Sources */ = {isa = PBXBuildFile; fileRef = 66FA12B52E99726C00A1F3C2 /* AVAssetReaderTrackOutputWrapper.m */; settings = {COMPILER_FLAGS = "-fobjc-exceptions"; }; };
66FA2B1D28CB0DE1006845CD /* PaymentsBiometryLockPromptViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FA2B1C28CB0DE1006845CD /* PaymentsBiometryLockPromptViewController.swift */; };
66FBC4E128DA820900BD9E8B /* MyStorySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FBC4E028DA820900BD9E8B /* MyStorySettingsViewController.swift */; };
66FBC4E328DA82AA00BD9E8B /* SelectMyStoryRecipientsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66FBC4E228DA82AA00BD9E8B /* SelectMyStoryRecipientsViewController.swift */; };
@ -5158,7 +5142,6 @@
665C0D5D2ADF53E200539A37 /* BackupArchiveManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupArchiveManagerImpl.swift; sourceTree = "<group>"; };
665C0D5F2ADF57D000539A37 /* BackupArchive+Shims.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BackupArchive+Shims.swift"; sourceTree = "<group>"; };
665C0D612AE0552900539A37 /* BackupArchiveProtoStreamProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupArchiveProtoStreamProvider.swift; sourceTree = "<group>"; };
665C758B2C35A55300D2E4BA /* TSAttachmentMigration+ThreadWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+ThreadWallpaper.swift"; sourceTree = "<group>"; };
665CBD042BADC87A0059EA4F /* DraftQuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftQuotedReplyModel.swift; sourceTree = "<group>"; };
665D9B442C111C6D00E73E94 /* AttachmentMultisend+OversizeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentMultisend+OversizeText.swift"; sourceTree = "<group>"; };
665EF86C290C385B00F490D2 /* OWSNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSNavigationController.swift; sourceTree = "<group>"; };
@ -5166,7 +5149,6 @@
665FAE8B2A02C0D400FA298D /* SpoilerRevealState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpoilerRevealState.swift; sourceTree = "<group>"; };
6660725D2BAB36960084B3D2 /* AttachmentDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDataSource.swift; sourceTree = "<group>"; };
666072602BAB58850084B3D2 /* OWSContactSerializationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSContactSerializationTest.swift; sourceTree = "<group>"; };
6660C7962C45C34A00D9C30A /* TSAttachmentMigration+TSMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+TSMessage.swift"; sourceTree = "<group>"; };
6664B9AA2A314EBD008EF74B /* SpoilerRevealStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpoilerRevealStateTests.swift; sourceTree = "<group>"; };
666654202AD0B03F00B23B32 /* MasterKeySyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterKeySyncManager.swift; sourceTree = "<group>"; };
66681CDE2C58174F00E50136 /* BackupAttachmentDownloadStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupAttachmentDownloadStoreTests.swift; sourceTree = "<group>"; };
@ -5261,13 +5243,6 @@
6691E7EE2996E8FB0032A68A /* TSRequestOWSURLSessionMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSRequestOWSURLSessionMock.swift; sourceTree = "<group>"; };
6691E7F12996E9BC0032A68A /* RegistrationSessionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationSessionManagerMock.swift; sourceTree = "<group>"; };
6691E7F62996EAD70032A68A /* SecureValueRecoveryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureValueRecoveryMock.swift; sourceTree = "<group>"; };
669379EC2C3C5B2C00EED7A0 /* TSAttachmentMigration+Records.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+Records.swift"; sourceTree = "<group>"; };
669379EE2C3C5E5800EED7A0 /* TSAttachmentMigration+AudioWaveformManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+AudioWaveformManager.swift"; sourceTree = "<group>"; };
669379F02C3C79E800EED7A0 /* TSAttachmentMigration+OWSMediaUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+OWSMediaUtils.swift"; sourceTree = "<group>"; };
669379F22C3C7C3B00EED7A0 /* TSAttachmentMigration+OWSImageSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+OWSImageSource.swift"; sourceTree = "<group>"; };
669379F42C3C7EA800EED7A0 /* TSAttachmentMigration+ImageMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+ImageMetadata.swift"; sourceTree = "<group>"; };
669379F62C3C847000EED7A0 /* TSAttachmentMigration+AttachmentValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+AttachmentValidator.swift"; sourceTree = "<group>"; };
66937A022C3F4EFC00EED7A0 /* TSAttachmentMigration+StoryMessageAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentMigration+StoryMessageAttachment.swift"; sourceTree = "<group>"; };
6694BAB22CE579270015633F /* BackupArchiveProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupArchiveProgress.swift; sourceTree = "<group>"; };
6694BF672B36484800B18764 /* PinnedThreadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedThreadManager.swift; sourceTree = "<group>"; };
6694BF692B3650E400B18764 /* PinnedThreadStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedThreadStore.swift; sourceTree = "<group>"; };
@ -5296,7 +5271,6 @@
669E900628B43F5B00043D28 /* SystemStoryManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStoryManagerProtocol.swift; sourceTree = "<group>"; };
669E900F28B57D6300043D28 /* SystemStoryManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStoryManagerMock.swift; sourceTree = "<group>"; };
669FAE1A2B7AC919009EE2FE /* OWSLinkPreviewSerializationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSLinkPreviewSerializationTest.swift; sourceTree = "<group>"; };
66A1ABE12C3311B40033C5EB /* TSAttachmentMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSAttachmentMigration.swift; sourceTree = "<group>"; };
66A1DF72298C635E00C4E4A7 /* RegistrationRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationRequestFactory.swift; sourceTree = "<group>"; };
66A1DF74298C73D900C4E4A7 /* RegistrationServiceResponses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationServiceResponses.swift; sourceTree = "<group>"; };
66A1F4E12E035BFE0095DE4B /* BackupExportJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupExportJob.swift; sourceTree = "<group>"; };
@ -5320,7 +5294,6 @@
66B152AB2DD6FD9400DE25CC /* AttachmentOffloadingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentOffloadingManager.swift; sourceTree = "<group>"; };
66B1E26B2CB187A0005F43AC /* AttachmentUploadStoreImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadStoreImpl.swift; sourceTree = "<group>"; };
66B1E26F2CB48C48005F43AC /* Array+SSKTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+SSKTest.swift"; sourceTree = "<group>"; };
66B2FBFD2D10F5DE00189908 /* IncrementalMessageTSAttachmentMigratorFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncrementalMessageTSAttachmentMigratorFactory.swift; sourceTree = "<group>"; };
66B545192DD5B9980016289B /* BackupListMediaManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupListMediaManager.swift; sourceTree = "<group>"; };
66B78E022BE59B860022580E /* StickerMetadata+TSResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StickerMetadata+TSResource.swift"; sourceTree = "<group>"; };
66B78E052BE5AADF0022580E /* AttachmentViewOnceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentViewOnceManager.swift; sourceTree = "<group>"; };
@ -5341,9 +5314,6 @@
66C1A87E2BB77E950076C65A /* AttachmentUploadManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadManagerTests.swift; sourceTree = "<group>"; };
66C1A8812BB77EBB0076C65A /* AttachmentUploadManagerTestHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadManagerTestHelper.swift; sourceTree = "<group>"; };
66C1A8832BB77EC60076C65A /* AttachmentUploadManagerTestMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadManagerTestMocks.swift; sourceTree = "<group>"; };
66C1BF502D0CC7C7002296F7 /* IncrementalTSAttachmentMigrationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncrementalTSAttachmentMigrationStore.swift; sourceTree = "<group>"; };
66C1BF522D0CC7DB002296F7 /* IncrementalMessageTSAttachmentMigrator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncrementalMessageTSAttachmentMigrator.swift; sourceTree = "<group>"; };
66C1BF542D0CC881002296F7 /* IncrementalMessageTSAttachmentMigrationRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncrementalMessageTSAttachmentMigrationRunner.swift; sourceTree = "<group>"; };
66C2B1302A05D28A008DDE72 /* TSRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TSRequest.swift; sourceTree = "<group>"; };
66C2B1352A0DB02E008DDE72 /* SVRUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SVRUtil.swift; sourceTree = "<group>"; };
66C2B1372A0DB6A9008DDE72 /* SVRAuthCredential.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SVRAuthCredential.swift; sourceTree = "<group>"; };
@ -5441,8 +5411,6 @@
66F98DE52DBBED68009F1A86 /* LineWrappingStackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineWrappingStackView.swift; sourceTree = "<group>"; };
66F98DE72DBBF24F009F1A86 /* LineWrappingStackViewTestController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineWrappingStackViewTestController.swift; sourceTree = "<group>"; };
66F98DE92DC5314E009F1A86 /* ChatListViewController+BackupDownloadProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListViewController+BackupDownloadProgressView.swift"; sourceTree = "<group>"; };
66FA12B32E9971E300A1F3C2 /* AVAssetReaderTrackOutputWrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AVAssetReaderTrackOutputWrapper.h; sourceTree = "<group>"; };
66FA12B52E99726C00A1F3C2 /* AVAssetReaderTrackOutputWrapper.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AVAssetReaderTrackOutputWrapper.m; sourceTree = "<group>"; };
66FA2B1C28CB0DE1006845CD /* PaymentsBiometryLockPromptViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentsBiometryLockPromptViewController.swift; sourceTree = "<group>"; };
66FA2B1E28CBA4A5006845CD /* DeviceOwnerAuthenticationType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceOwnerAuthenticationType.swift; sourceTree = "<group>"; };
66FBC4E028DA820900BD9E8B /* MyStorySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyStorySettingsViewController.swift; sourceTree = "<group>"; };
@ -10399,33 +10367,10 @@
66A1ABDF2C3311800033C5EB /* IncrementalMigrations */ = {
isa = PBXGroup;
children = (
66A1ABE02C33118A0033C5EB /* TSAttachment */,
);
path = IncrementalMigrations;
sourceTree = "<group>";
};
66A1ABE02C33118A0033C5EB /* TSAttachment */ = {
isa = PBXGroup;
children = (
66FA12B32E9971E300A1F3C2 /* AVAssetReaderTrackOutputWrapper.h */,
66FA12B52E99726C00A1F3C2 /* AVAssetReaderTrackOutputWrapper.m */,
66C1BF522D0CC7DB002296F7 /* IncrementalMessageTSAttachmentMigrator.swift */,
66B2FBFD2D10F5DE00189908 /* IncrementalMessageTSAttachmentMigratorFactory.swift */,
66C1BF502D0CC7C7002296F7 /* IncrementalTSAttachmentMigrationStore.swift */,
669379F62C3C847000EED7A0 /* TSAttachmentMigration+AttachmentValidator.swift */,
669379EE2C3C5E5800EED7A0 /* TSAttachmentMigration+AudioWaveformManager.swift */,
669379F42C3C7EA800EED7A0 /* TSAttachmentMigration+ImageMetadata.swift */,
669379F22C3C7C3B00EED7A0 /* TSAttachmentMigration+OWSImageSource.swift */,
669379F02C3C79E800EED7A0 /* TSAttachmentMigration+OWSMediaUtils.swift */,
669379EC2C3C5B2C00EED7A0 /* TSAttachmentMigration+Records.swift */,
66937A022C3F4EFC00EED7A0 /* TSAttachmentMigration+StoryMessageAttachment.swift */,
665C758B2C35A55300D2E4BA /* TSAttachmentMigration+ThreadWallpaper.swift */,
6660C7962C45C34A00D9C30A /* TSAttachmentMigration+TSMessage.swift */,
66A1ABE12C3311B40033C5EB /* TSAttachmentMigration.swift */,
);
path = TSAttachment;
sourceTree = "<group>";
};
66A1DF71298C634500C4E4A7 /* Registration */ = {
isa = PBXGroup;
children = (
@ -10974,7 +10919,6 @@
66DA8DF92C91125200799E70 /* AttachmentValidationBackfillRunner.swift */,
66A1F4E52E0364140095DE4B /* BackupBGProcessingTaskRunner.swift */,
66DA8DF72C910D3B00799E70 /* BGProcessingTaskRunner.swift */,
66C1BF542D0CC881002296F7 /* IncrementalMessageTSAttachmentMigrationRunner.swift */,
66CDB7512AF9D117009A36EC /* MessageFetchBGRefreshTask.swift */,
);
path = src;
@ -15330,7 +15274,6 @@
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
files = (
66FA12B42E9971E300A1F3C2 /* AVAssetReaderTrackOutputWrapper.h in Headers */,
F9C5CD77289453B300548EEE /* BaseModel.h in Headers */,
668A00E02C2B5ECF007B8808 /* DebuggerUtils.h in Headers */,
F9C5CC0A289453B300548EEE /* InstalledSticker.h in Headers */,
@ -18041,7 +17984,6 @@
04BBBE902E259A6900E914B1 /* InactivePrimaryDeviceReminderMegaphone.swift in Sources */,
D9E43C052CC194140001536E /* IncomingCallControls.swift in Sources */,
D9E43C062CC194140001536E /* IncomingReactionsView.swift in Sources */,
66C1BF552D0CC88A002296F7 /* IncrementalMessageTSAttachmentMigrationRunner.swift in Sources */,
D9E43C2A2CC194140001536E /* IndividualCall.swift in Sources */,
D9E43C2B2CC194140001536E /* IndividualCallService.swift in Sources */,
D9E43C072CC194140001536E /* IndividualCallViewController.swift in Sources */,
@ -18543,7 +18485,6 @@
500AEE092A4E09AD00371F05 /* AuthorMergeObserver.swift in Sources */,
6649651C2BDC6EAD00E2DE98 /* AVAsset+Attachment.swift in Sources */,
058B49932C66805500307D38 /* AVAssetExportSession+Async.swift in Sources */,
66FA12B62E99727300A1F3C2 /* AVAssetReaderTrackOutputWrapper.m in Sources */,
7254651E2BA012BD00EABFD2 /* AvatarBuilder.swift in Sources */,
D93F4D5A2D800DD20042926C /* AvatarDefaultColorManager.swift in Sources */,
720547F22B9C8F9900E2CF2F /* AvatarModel.swift in Sources */,
@ -18921,9 +18862,6 @@
D9AE0ACF29186D7F0063488B /* IncomingContactSyncJobRecord.swift in Sources */,
6640DD602ACDBEC500CE9A8C /* IncomingPniChangeNumberProcessor.swift in Sources */,
F9C5CC69289453B300548EEE /* IncompleteCallsJob.swift in Sources */,
66C1BF532D0CC7EB002296F7 /* IncrementalMessageTSAttachmentMigrator.swift in Sources */,
66B2FBFE2D10F5EB00189908 /* IncrementalMessageTSAttachmentMigratorFactory.swift in Sources */,
66C1BF512D0CC7C9002296F7 /* IncrementalTSAttachmentMigrationStore.swift in Sources */,
D979CC262AD3933B006AAC49 /* IndividualCallRecordManager.swift in Sources */,
D9B95A9B29E8923B00D7CB95 /* InMemoryDB.swift in Sources */,
C1DD78AB2BB1CEF80020F064 /* InputStreamable.swift in Sources */,
@ -19540,16 +19478,6 @@
661170C42ABA4D9900A1B16D /* TSAccountManager.swift in Sources */,
661170C82ABA4F3A00A1B16D /* TSAccountManagerImpl.swift in Sources */,
664657472ACB66630099DE1C /* TSAccountManagerObjcBridge.swift in Sources */,
669379F72C3C847000EED7A0 /* TSAttachmentMigration+AttachmentValidator.swift in Sources */,
669379EF2C3C5E5800EED7A0 /* TSAttachmentMigration+AudioWaveformManager.swift in Sources */,
669379F52C3C7EA800EED7A0 /* TSAttachmentMigration+ImageMetadata.swift in Sources */,
669379F32C3C7C3B00EED7A0 /* TSAttachmentMigration+OWSImageSource.swift in Sources */,
669379F12C3C79E800EED7A0 /* TSAttachmentMigration+OWSMediaUtils.swift in Sources */,
669379ED2C3C5B2C00EED7A0 /* TSAttachmentMigration+Records.swift in Sources */,
66937A032C3F4EFC00EED7A0 /* TSAttachmentMigration+StoryMessageAttachment.swift in Sources */,
665C758C2C35A55300D2E4BA /* TSAttachmentMigration+ThreadWallpaper.swift in Sources */,
6660C7972C45C34A00D9C30A /* TSAttachmentMigration+TSMessage.swift in Sources */,
66A1ABE22C3311B40033C5EB /* TSAttachmentMigration.swift in Sources */,
F9C5CC5C289453B300548EEE /* TSCall+SDS.swift in Sources */,
F9C5CC5D289453B300548EEE /* TSCall.m in Sources */,
D91AC93E2B6337B200814975 /* TSCall.swift in Sources */,

View File

@ -17,7 +17,6 @@ enum LaunchPreflightError {
case databaseCorruptedAndMightBeRecoverable
case databaseUnrecoverablyCorrupted
case lastAppLaunchCrashed
case incrementalTSAttachmentMigrationFailed
case lowStorageSpaceAvailable
case possibleReadCorruptionCrashed
@ -33,8 +32,6 @@ enum LaunchPreflightError {
return "LaunchFailure_DatabaseUnrecoverablyCorrupted"
case .lastAppLaunchCrashed:
return "LaunchFailure_LastAppLaunchCrashed"
case .incrementalTSAttachmentMigrationFailed:
return "LaunchFailure_incrementalTSAttachmentMigrationFailed"
case .lowStorageSpaceAvailable:
return "LaunchFailure_NoDiskSpaceAvailable"
case .possibleReadCorruptionCrashed:
@ -259,24 +256,12 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// Do this even if `appVersion` isn't used -- there's side effects.
let appVersion = AppVersionImpl.shared
// Set up and register incremental migration for TSAttachment -> v2 Attachment.
// TODO: remove this (and the incremental migrator itself) once we make this
// migration a launch-blocking GRDB migration.
let incrementalMessageTSAttachmentMigrationStore = IncrementalTSAttachmentMigrationStore(
userDefaults: mainAppContext.appUserDefaults()
)
let incrementalMessageTSAttachmentMigratorFactory = IncrementalMessageTSAttachmentMigratorFactoryImpl(
store: incrementalMessageTSAttachmentMigrationStore
)
let launchContext = LaunchContext(
appContext: mainAppContext,
databaseStorage: databaseStorage,
deviceSleepManager: deviceSleepManager,
keychainStorage: keychainStorage,
launchStartedAt: launchStartedAt,
incrementalMessageTSAttachmentMigrationStore: incrementalMessageTSAttachmentMigrationStore,
incrementalMessageTSAttachmentMigratorFactory: incrementalMessageTSAttachmentMigratorFactory
)
// We need to do this _after_ we set up logging, when the keychain is unlocked,
@ -284,7 +269,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
let preflightError = checkIfAllowedToLaunch(
mainAppContext: mainAppContext,
appVersion: appVersion,
incrementalTSAttachmentMigrationStore: incrementalMessageTSAttachmentMigrationStore,
didDeviceTransferRestoreSucceed: didDeviceTransferRestoreSucceed
)
@ -311,14 +295,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
// We _must_ register BGProcessingTask handlers synchronously in didFinishLaunching.
// https://developer.apple.com/documentation/backgroundtasks/bgtaskscheduler/register(fortaskwithidentifier:using:launchhandler:)
// WARNING: Apple docs say we can only have 10 BGProcessingTasks registered.
let attachmentMigrationRunner = IncrementalMessageTSAttachmentMigrationRunner(
appContext: mainAppContext,
db: databaseStorage,
store: incrementalMessageTSAttachmentMigrationStore,
migrator: { DependenciesBridge.shared.incrementalMessageTSAttachmentMigrator }
)
attachmentMigrationRunner.registerBGProcessingTask(appReadiness: appReadiness)
let attachmentBackfillStore = AttachmentValidationBackfillStore()
let attachmentValidationRunner = AttachmentValidationBackfillRunner(
db: databaseStorage,
@ -345,9 +321,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
databaseMigratorRunner.registerBGProcessingTask(appReadiness: appReadiness)
appReadiness.runNowOrWhenAppDidBecomeReadyAsync {
if SSKEnvironment.shared.remoteConfigManagerRef.currentConfig().shouldRunTSAttachmentMigrationInBGProcessingTask {
attachmentMigrationRunner.scheduleBGProcessingTaskIfNeeded()
}
attachmentValidationRunner.scheduleBGProcessingTaskIfNeeded()
backupRunner.scheduleBGProcessingTaskIfNeeded()
}
@ -399,8 +372,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
let deviceSleepManager: DeviceSleepManagerImpl
let keychainStorage: any KeychainStorage
let launchStartedAt: CFTimeInterval
let incrementalMessageTSAttachmentMigrationStore: IncrementalTSAttachmentMigrationStore
let incrementalMessageTSAttachmentMigratorFactory: IncrementalMessageTSAttachmentMigratorFactory
}
private func launchApp(
@ -451,7 +422,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
callMessageHandler: WebRTCCallMessageHandler(),
currentCallProvider: currentCall,
notificationPresenter: NotificationPresenterImpl(),
incrementalMessageTSAttachmentMigratorFactory: launchContext.incrementalMessageTSAttachmentMigratorFactory
)
SUIEnvironment.shared.setUp(
appReadiness: appReadiness,
@ -478,20 +448,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
)
let finalContinuation = await dataMigrationContinuation.migrateDatabaseData()
finalContinuation.runLaunchTasksIfNeededAndReloadCaches()
guard BuildFlags.runTSAttachmentMigrationBlockingOnLaunch else {
return (finalContinuation, sleepBlockObject)
}
let progressSink = OWSProgress.createSink { [weak loadingViewController] progress in
await MainActor.run {
loadingViewController?.updateProgress(progress)
}
}
let migrateTask = Task {
_ = await finalContinuation.dependenciesBridge.incrementalMessageTSAttachmentMigrator
.runInMainAppUntilFinished(ignorePastFailures: true, progress: progressSink)
}
await migrateTask.value
return (finalContinuation, sleepBlockObject)
}
@ -1014,7 +971,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
private func checkIfAllowedToLaunch(
mainAppContext: MainAppContext,
appVersion: AppVersion,
incrementalTSAttachmentMigrationStore: IncrementalTSAttachmentMigrationStore,
didDeviceTransferRestoreSucceed: Bool
) -> LaunchPreflightError? {
guard checkEnoughDiskSpaceAvailable() else {
@ -1060,19 +1016,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
return .lastAppLaunchCrashed
}
if incrementalTSAttachmentMigrationStore.shouldReportFailureInUI() {
if let (logString, wasLoggedBefore) = incrementalTSAttachmentMigrationStore.consumeLastBGProcessingTaskError() {
if wasLoggedBefore {
Logger.error("Previously failed TSAttachment migration in some BGProcessingTask: \(logString)")
} else {
Logger.error("Failed TSAttachment migration in last BGProcessingTask: \(logString)")
}
} else if let checkpointString = incrementalTSAttachmentMigrationStore.getLastCheckpoint() {
Logger.error("Previously failed TSAttachment migration, last checkpoint: \(checkpointString)")
}
return .incrementalTSAttachmentMigrationFailed
}
return nil
}
@ -1129,7 +1072,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
)
actions = [.submitDebugLogsAndCrash]
case .lastAppLaunchCrashed, .incrementalTSAttachmentMigrationFailed:
case .lastAppLaunchCrashed:
title = OWSLocalizedString(
"APP_LAUNCH_FAILURE_LAST_LAUNCH_CRASHED_TITLE",
comment: "Error indicating that the app crashed during the previous launch."
@ -1271,7 +1214,6 @@ final class AppDelegate: UIResponder, UIApplicationDelegate {
func ignoreErrorAndLaunchApp(in window: UIWindow, launchContext: LaunchContext) {
// Pretend we didn't fail!
self.didAppLaunchFail = false
launchContext.incrementalMessageTSAttachmentMigrationStore.didReportFailureInUI()
let loadingViewController = LoadingViewController()
window.rootViewController = loadingViewController
self.launchApp(

View File

@ -6,7 +6,6 @@
<array>
<string>AttachmentValidationBackfillMigrator</string>
<string>BackupBGProcessingTaskRunner</string>
<string>MessageAttachmentMigrationTask</string>
<string>MessageFetchBGRefreshTask</string>
<string>LazyDatabaseMigratorTask</string>
</array>

View File

@ -1,81 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import SignalServiceKit
/// Manages the BGProcessingTask for doing the migration as well as the runner for
/// doing so while the main app is running.
class IncrementalMessageTSAttachmentMigrationRunner: BGProcessingTaskRunner {
private let appContext: AppContext
private let db: SDSDatabaseStorage
private let store: IncrementalTSAttachmentMigrationStore
private let migrator: () -> any IncrementalMessageTSAttachmentMigrator
init(
appContext: AppContext,
db: SDSDatabaseStorage,
store: IncrementalTSAttachmentMigrationStore,
migrator: @escaping () -> any IncrementalMessageTSAttachmentMigrator
) {
self.appContext = appContext
self.db = db
self.store = store
self.migrator = migrator
}
// MARK: - BGProcessingTaskRunner
static let taskIdentifier = "MessageAttachmentMigrationTask"
static let logPrefix: String? = nil
static let requiresNetworkConnectivity = false
static let requiresExternalPower = false
func run() async throws {
let logger = MigrationLogger(appContext: appContext, store: store)
try await self.runInBatches(
willBegin: { store.willAttemptMigrationUntilFinished() },
runNextBatch: {
return await migrator().runNextBatch(logger: logger)
}
)
}
public func startCondition() -> BGProcessingTaskStartCondition {
let state = db.read(block: store.getState(tx:))
if state != .finished {
return .asSoonAsPossible
} else {
return .never
}
}
private class MigrationLogger: TSAttachmentMigrationLogger {
private let appContext: AppContext
private let store: IncrementalTSAttachmentMigrationStore
init(
appContext: AppContext,
store: IncrementalTSAttachmentMigrationStore
) {
self.appContext = appContext
self.store = store
}
func didFatalError(_ logString: String) {
let logString = ScrubbingLogFormatter().redactMessage(logString)
store.bgProcessingTaskDidExperienceError(logString: logString)
}
func flagDBCorrupted() {
DatabaseCorruptionState.flagDatabaseAsCorrupted(userDefaults: appContext.appUserDefaults())
}
func checkpoint(_ checkpointString: String) {
store.saveLastCheckpoint(checkpointString)
}
}
}

View File

@ -70,7 +70,6 @@ class NSEEnvironment {
callMessageHandler: NSECallMessageHandler(),
currentCallProvider: CurrentCallNoOpProvider(),
notificationPresenter: NotificationPresenterImpl(),
incrementalMessageTSAttachmentMigratorFactory: NoOpIncrementalMessageTSAttachmentMigratorFactory(),
)
.migrateDatabaseData()

View File

@ -59,7 +59,6 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
private let encryptedStreamProvider: BackupArchiveEncryptedProtoStreamProvider
private let fullTextSearchIndexer: BackupArchiveFullTextSearchIndexer
private let groupRecipientArchiver: BackupArchiveGroupRecipientArchiver
private let incrementalTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator
private let kvStore: KeyValueStore
private let libsignalNet: LibSignalClient.Net
private let localStorage: AccountKeyStore
@ -101,7 +100,6 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
encryptedStreamProvider: BackupArchiveEncryptedProtoStreamProvider,
fullTextSearchIndexer: BackupArchiveFullTextSearchIndexer,
groupRecipientArchiver: BackupArchiveGroupRecipientArchiver,
incrementalTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator,
libsignalNet: LibSignalClient.Net,
localStorage: AccountKeyStore,
localRecipientArchiver: BackupArchiveLocalRecipientArchiver,
@ -139,7 +137,6 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
self.encryptedStreamProvider = encryptedStreamProvider
self.fullTextSearchIndexer = fullTextSearchIndexer
self.groupRecipientArchiver = groupRecipientArchiver
self.incrementalTSAttachmentMigrator = incrementalTSAttachmentMigrator
self.kvStore = KeyValueStore(collection: Constants.keyValueStoreCollectionName)
self.libsignalNet = libsignalNet
self.localStorage = localStorage
@ -382,14 +379,9 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
DBReadTransaction
) -> BackupArchive.ProtoStream.OpenOutputStreamResult<OutputStreamMetadata>
) async throws -> OutputStreamMetadata {
let migrateAttachmentsProgressSink: OWSProgressSink?
let prepareOversizeTextAttachmentsProgressSink: OWSProgressSink?
let exportProgress: BackupArchiveExportProgress?
if let progressSink {
migrateAttachmentsProgressSink = await progressSink.addChild(
withLabel: "Export Backup: Migrate Attachments",
unitCount: 5
)
prepareOversizeTextAttachmentsProgressSink = await progressSink.addChild(
withLabel: "Export Backup: Oversize Text Attachments",
unitCount: 5
@ -397,18 +389,15 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
exportProgress = try await .prepare(
sink: await progressSink.addChild(
withLabel: "Export Backup: Export Frames",
unitCount: 90
unitCount: 95
),
db: db
)
} else {
migrateAttachmentsProgressSink = nil
prepareOversizeTextAttachmentsProgressSink = nil
exportProgress = nil
}
await migrateAttachmentsBeforeBackup(progress: migrateAttachmentsProgressSink)
try await oversizeTextArchiver.populateTableIncrementally(progress: prepareOversizeTextAttachmentsProgressSink)
// Before we export, we need to make sure we have an MRBK the export
@ -847,19 +836,14 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
DBReadTransaction
) -> BackupArchive.ProtoStream.OpenInputStreamResult
) async throws {
let migrateAttachmentsProgressSink: OWSProgressSink?
let frameRestoreProgress: BackupArchiveImportFramesProgress?
let recreateIndexesProgress: BackupArchiveImportRecreateIndexesProgress?
let finalizeProgress: OWSProgressSink?
if let progressSink {
migrateAttachmentsProgressSink = await progressSink.addChild(
withLabel: "Import Backup: Migrate Attachments",
unitCount: 5
)
frameRestoreProgress = try await .prepare(
sink: await progressSink.addChild(
withLabel: "Import Backup: Import Frames",
unitCount: 78
unitCount: 83
),
fileUrl: fileUrl
)
@ -874,14 +858,11 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
unitCount: 5
)
} else {
migrateAttachmentsProgressSink = nil
frameRestoreProgress = nil
recreateIndexesProgress = nil
finalizeProgress = nil
}
await migrateAttachmentsBeforeBackup(progress: migrateAttachmentsProgressSink)
let backupInfo = try await db.awaitableWriteWithRollbackIfThrows { tx in
return try BenchMemory(
title: benchTitle,
@ -1469,27 +1450,6 @@ public class BackupArchiveManagerImpl: BackupArchiveManager {
}
}
/// TSAttachments must be migrated to v2 Attachments before we can create or restore backups.
/// Normally this migration happens in the background; force it to run and finish now.
private func migrateAttachmentsBeforeBackup(progress: OWSProgressSink?) async {
let didMigrateAnything = await incrementalTSAttachmentMigrator.runInMainAppUntilFinished(
ignorePastFailures: true,
progress: progress
)
if
let progress,
!didMigrateAnything
{
// Nothing was migrated, so progress wasn't updated. Complete it!
let source = await progress.addSource(
withLabel: "TSAttachmentMigrator had nothing to do",
unitCount: 1
)
source.complete()
}
}
private func validateEncryptedBackup(
fileUrl: URL,
backupEncryptionKey: MessageBackupKey,

View File

@ -179,7 +179,6 @@ extension AppSetup.GlobalsContinuation {
callMessageHandler: CallMessageHandler,
currentCallProvider: any CurrentCallProvider,
notificationPresenter: any NotificationPresenter,
incrementalMessageTSAttachmentMigratorFactory: IncrementalMessageTSAttachmentMigratorFactory,
testDependencies: AppSetup.TestDependencies = AppSetup.TestDependencies(),
) -> AppSetup.DataMigrationContinuation {
configureUnsatisfiableConstraintLogging()
@ -1350,14 +1349,6 @@ extension AppSetup.GlobalsContinuation {
usernameLookupManager: usernameLookupManager
)
let incrementalMessageTSAttachmentMigrator = incrementalMessageTSAttachmentMigratorFactory.migrator(
appContext: appContext,
appReadiness: appReadiness,
databaseStorage: databaseStorage,
remoteConfigManager: remoteConfigManager,
tsAccountManager: tsAccountManager
)
let backupAttachmentsArchiver = BackupArchiveMessageAttachmentArchiver(
attachmentManager: attachmentManager,
attachmentStore: attachmentStore,
@ -1489,7 +1480,6 @@ extension AppSetup.GlobalsContinuation {
storyStore: backupStoryStore,
threadStore: backupThreadStore
),
incrementalTSAttachmentMigrator: incrementalMessageTSAttachmentMigrator,
libsignalNet: libsignalNet,
localStorage: accountKeyStore,
localRecipientArchiver: BackupArchiveLocalRecipientArchiver(
@ -1696,7 +1686,6 @@ extension AppSetup.GlobalsContinuation {
incomingCallEventSyncMessageManager: incomingCallEventSyncMessageManager,
incomingCallLogEventSyncMessageManager: incomingCallLogEventSyncMessageManager,
incomingPniChangeNumberProcessor: incomingPniChangeNumberProcessor,
incrementalMessageTSAttachmentMigrator: incrementalMessageTSAttachmentMigrator,
individualCallRecordManager: individualCallRecordManager,
interactionDeleteManager: interactionDeleteManager,
interactionStore: interactionStore,

View File

@ -122,7 +122,6 @@ public class DependenciesBridge {
let incomingCallEventSyncMessageManager: IncomingCallEventSyncMessageManager
let incomingCallLogEventSyncMessageManager: IncomingCallLogEventSyncMessageManager
public let incomingPniChangeNumberProcessor: IncomingPniChangeNumberProcessor
public let incrementalMessageTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator
public let individualCallRecordManager: IndividualCallRecordManager
public let interactionDeleteManager: InteractionDeleteManager
public let interactionStore: InteractionStore
@ -260,7 +259,6 @@ public class DependenciesBridge {
incomingCallEventSyncMessageManager: IncomingCallEventSyncMessageManager,
incomingCallLogEventSyncMessageManager: IncomingCallLogEventSyncMessageManager,
incomingPniChangeNumberProcessor: IncomingPniChangeNumberProcessor,
incrementalMessageTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator,
individualCallRecordManager: IndividualCallRecordManager,
interactionDeleteManager: InteractionDeleteManager,
interactionStore: InteractionStore,
@ -397,7 +395,6 @@ public class DependenciesBridge {
self.incomingCallEventSyncMessageManager = incomingCallEventSyncMessageManager
self.incomingCallLogEventSyncMessageManager = incomingCallLogEventSyncMessageManager
self.incomingPniChangeNumberProcessor = incomingPniChangeNumberProcessor
self.incrementalMessageTSAttachmentMigrator = incrementalMessageTSAttachmentMigrator
self.individualCallRecordManager = individualCallRecordManager
self.interactionDeleteManager = interactionDeleteManager
self.interactionStore = interactionStore

View File

@ -236,10 +236,6 @@ public class RemoteConfig {
}
}
public var tsAttachmentMigrationBatchDelayMs: UInt64 {
getUInt64Value(forFlag: .tsAttachmentMigrationBatchDelayMs, defaultValue: 50)
}
public var backupListMediaDefaultRefreshInterval: TimeInterval {
let defaultValue: UInt64
if BuildFlags.Backups.useLowerDefaultListMediaRefreshInterval {
@ -287,14 +283,6 @@ public class RemoteConfig {
return UInt64(messageQueueTime * Double(MSEC_PER_SEC))
}
public var shouldRunTSAttachmentMigrationInBGProcessingTask: Bool {
return !isEnabled(.tsAttachmentMigrationBGProcessingTaskKillSwitch)
}
public var shouldRunTSAttachmentMigrationInMainAppBackground: Bool {
return !isEnabled(.tsAttachmentMigrationMainAppBackgroundKillSwitch)
}
public var backupSettingsKillSwitch: Bool {
return isEnabled(.backupSettingsKillSwitch)
}
@ -528,8 +516,6 @@ private enum IsEnabledFlag: String, FlagType {
case pollReceiveKillSwitch = "ios.pollReceiveKillSwitch"
case ringrtcNwPathMonitorTrialKillSwitch = "ios.ringrtcNwPathMonitorTrialKillSwitch"
case serviceExtensionFailureKillSwitch = "ios.serviceExtensionFailureKillSwitch"
case tsAttachmentMigrationBGProcessingTaskKillSwitch = "ios.tsAttachmentMigrationBGProcessingTaskKillSwitch"
case tsAttachmentMigrationMainAppBackgroundKillSwitch = "ios.tsAttachmentMigrationMainAppBackgroundKillSwitch"
#if TESTABLE_BUILD
case hotSwappable = "test.hotSwappable.enabled"
@ -557,8 +543,6 @@ private enum IsEnabledFlag: String, FlagType {
case .pollReceiveKillSwitch: true
case .ringrtcNwPathMonitorTrialKillSwitch: true // cached during launch, so not hot-swapped in practice
case .serviceExtensionFailureKillSwitch: true
case .tsAttachmentMigrationBGProcessingTaskKillSwitch: true
case .tsAttachmentMigrationMainAppBackgroundKillSwitch: true
#if TESTABLE_BUILD
case .hotSwappable: true
@ -592,7 +576,6 @@ private enum ValueFlag: String, FlagType {
case replaceableInteractionExpiration = "ios.replaceableInteractionExpiration"
case sepaEnabledRegions = "global.donations.sepaEnabledRegions"
case standardMediaQualityLevel = "ios.standardMediaQualityLevel"
case tsAttachmentMigrationBatchDelayMs = "ios.tsAttachmentMigrationBatchDelayMs"
case backupListMediaDefaultRefreshIntervalMs = "ios.backupListMediaDefaultRefreshIntervalMs"
case backupListMediaOutOfQuotaRefreshIntervalMs = "ios.backupListMediaOutOfQuotaRefreshIntervalMs"
case pinnedMessageLimit = "global.pinned_message_limit"
@ -627,7 +610,6 @@ private enum ValueFlag: String, FlagType {
case .replaceableInteractionExpiration: false
case .sepaEnabledRegions: true
case .standardMediaQualityLevel: false
case .tsAttachmentMigrationBatchDelayMs: true
case .backupListMediaDefaultRefreshIntervalMs: true
case .backupListMediaOutOfQuotaRefreshIntervalMs: true
case .pinnedMessageLimit: true

View File

@ -2048,78 +2048,6 @@ CREATE
)
;
CREATE
TABLE
IF NOT EXISTS "model_TSAttachment" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
,"recordType" INTEGER NOT NULL
,"uniqueId" TEXT NOT NULL UNIQUE
ON CONFLICT FAIL
,"albumMessageId" TEXT
,"attachmentType" INTEGER NOT NULL
,"blurHash" TEXT
,"byteCount" INTEGER NOT NULL
,"caption" TEXT
,"contentType" TEXT NOT NULL
,"encryptionKey" BLOB
,"serverId" INTEGER NOT NULL
,"sourceFilename" TEXT
,"cachedAudioDurationSeconds" DOUBLE
,"cachedImageHeight" DOUBLE
,"cachedImageWidth" DOUBLE
,"creationTimestamp" DOUBLE
,"digest" BLOB
,"isUploaded" INTEGER
,"isValidImageCached" INTEGER
,"isValidVideoCached" INTEGER
,"lazyRestoreFragmentId" TEXT
,"localRelativeFilePath" TEXT
,"mediaSize" BLOB
,"pointerType" INTEGER
,"state" INTEGER
,"uploadTimestamp" INTEGER NOT NULL DEFAULT 0
,"cdnKey" TEXT NOT NULL DEFAULT ''
,"cdnNumber" INTEGER NOT NULL DEFAULT 0
,"isAnimatedCached" INTEGER
,"attachmentSchemaVersion" INTEGER DEFAULT 0
,"videoDuration" DOUBLE
,"clientUuid" TEXT
)
;
CREATE
INDEX "index_model_TSAttachment_on_uniqueId_and_contentType"
ON "model_TSAttachment"("uniqueId"
,"contentType"
)
;
CREATE
TABLE
IF NOT EXISTS "TSAttachmentMigration" (
"tsAttachmentUniqueId" TEXT NOT NULL
,"interactionRowId" INTEGER
,"storyMessageRowId" INTEGER
,"reservedV2AttachmentPrimaryFileId" BLOB NOT NULL
,"reservedV2AttachmentAudioWaveformFileId" BLOB NOT NULL
,"reservedV2AttachmentVideoStillFrameFileId" BLOB NOT NULL
)
;
CREATE
INDEX "index_TSAttachmentMigration_on_interactionRowId"
ON "TSAttachmentMigration" ("interactionRowId")
WHERE
"interactionRowId" IS NOT NULL
;
CREATE
INDEX "index_TSAttachmentMigration_on_storyMessageRowId"
ON "TSAttachmentMigration" ("storyMessageRowId")
WHERE
"storyMessageRowId" IS NOT NULL
;
CREATE
TABLE
IF NOT EXISTS "BlockedGroup" (

View File

@ -11,7 +11,6 @@ FOUNDATION_EXPORT double SignalServiceKitVersionNumber;
//! Project version string for SignalServiceKit.
FOUNDATION_EXPORT const unsigned char SignalServiceKitVersionString[];
#import <SignalServiceKit/AVAssetReaderTrackOutputWrapper.h>
#import <SignalServiceKit/BaseModel.h>
#import <SignalServiceKit/DebuggerUtils.h>
#import <SignalServiceKit/InstalledSticker.h>

View File

@ -301,8 +301,6 @@ public extension DatabaseRecovery {
QueuedBackupStickerPackDownload.databaseTableName,
OrphanedBackupAttachment.databaseTableName,
"MessageBackupAvatarFetchQueue",
"model_TSAttachment",
"TSAttachmentMigration",
"AvatarDefaultColor",
GroupMessageProcessorJob.databaseTableName,
"ListedBackupMediaObject",

View File

@ -255,12 +255,7 @@ public class GRDBSchemaMigrator {
case createArchivedPaymentTable
case removeDeadEndGroupThreadIdMappings
case addTSAttachmentMigrationTable
case threadWallpaperTSAttachmentMigration1
case threadWallpaperTSAttachmentMigration2
case threadWallpaperTSAttachmentMigration3
case indexMessageAttachmentReferenceByReceivedAtTimestamp
case migrateStoryMessageTSAttachments1
case migrateStoryMessageTSAttachments2
case addBackupAttachmentDownloadQueue
case createAttachmentUploadRecordTable
case addBlockedRecipient
@ -337,6 +332,7 @@ public class GRDBSchemaMigrator {
case fixNameForRestoredCallLinks
case addPinnedMessagesTable
case addPinnedAtTimestampToPinnedMessageTable
case dropTSAttachment
// NOTE: Every time we add a migration id, consider
// incrementing grdbSchemaVersionLatest.
@ -448,6 +444,13 @@ public class GRDBSchemaMigrator {
// Obsoleted by dataMigration_removeSystemContacts.
case dataMigration_removeLinkedDeviceSystemContacts
// Obsoleted when we removed the TSAttachment migration.
case threadWallpaperTSAttachmentMigration1
case threadWallpaperTSAttachmentMigration2
case threadWallpaperTSAttachmentMigration3
case migrateStoryMessageTSAttachments1
case migrateStoryMessageTSAttachments2
#endif
}
@ -3067,21 +3070,6 @@ public class GRDBSchemaMigrator {
return .success(())
}
migrator.registerMigration(.threadWallpaperTSAttachmentMigration1) { tx in
try TSAttachmentMigration.prepareThreadWallpaperMigration(tx: tx)
return .success(())
}
migrator.registerMigration(.threadWallpaperTSAttachmentMigration2) { tx in
try TSAttachmentMigration.completeThreadWallpaperMigration(tx: tx)
return .success(())
}
migrator.registerMigration(.threadWallpaperTSAttachmentMigration3) { tx in
try TSAttachmentMigration.cleanUpLegacyThreadWallpaperDirectory()
return .success(())
}
migrator.registerMigration(.indexMessageAttachmentReferenceByReceivedAtTimestamp) { tx in
try tx.database.create(
index: "index_message_attachment_reference_on_receivedAtTimestamp",
@ -3091,16 +3079,6 @@ public class GRDBSchemaMigrator {
return .success(())
}
migrator.registerMigration(.migrateStoryMessageTSAttachments1) { tx in
try TSAttachmentMigration.StoryMessageMigration.prepareStoryMessageMigration(tx: tx)
return .success(())
}
migrator.registerMigration(.migrateStoryMessageTSAttachments2) { tx in
try TSAttachmentMigration.StoryMessageMigration.completeStoryMessageMigration(tx: tx)
return .success(())
}
migrator.registerMigration(.addBackupAttachmentDownloadQueue) { tx in
try tx.database.create(table: "BackupAttachmentDownloadQueue") { table in
table.autoIncrementedPrimaryKey("id")
@ -4326,6 +4304,22 @@ public class GRDBSchemaMigrator {
return .success(())
}
migrator.registerMigration(.dropTSAttachment) { tx in
// Delete the legacy attachments folder, which is hopefully already
// deleted.
if let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup,
) {
let tsAttachmentsDir = containerURL.path.appendingPathComponent("Attachments")
try? FileManager.default.removeItem(atPath: tsAttachmentsDir)
}
try tx.database.drop(table: "TSAttachmentMigration")
try tx.database.drop(table: "model_TSAttachment")
return .success(())
}
// MARK: - Schema Migration Insertion Point
}

View File

@ -1,20 +0,0 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
#import <AVFoundation/AVFoundation.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface AVAssetReaderTrackOutputWrapper : NSObject
/// Safely creates an AVAssetReaderTrackOutput instance. Returns nil if creation fails.
+ (nullable AVAssetReaderTrackOutput *)safeAssetReaderTrackOutputWithTrack:(AVAssetTrack *)track
outputSettings:
(nullable NSDictionary<NSString *, id> *)outputSettings;
@end
NS_ASSUME_NONNULL_END

View File

@ -1,24 +0,0 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
#import "AVAssetReaderTrackOutputWrapper.h"
@implementation AVAssetReaderTrackOutputWrapper
+ (nullable AVAssetReaderTrackOutput *)safeAssetReaderTrackOutputWithTrack:(AVAssetTrack *)track
outputSettings:
(nullable NSDictionary<NSString *, id> *)outputSettings
{
@try {
AVAssetReaderTrackOutput *_Nullable output =
[AVAssetReaderTrackOutput assetReaderTrackOutputWithTrack:track outputSettings:outputSettings];
return output;
} @catch (NSException *exception) {
OWSFailDebug(@"Unable to generate AVAssetReaderTrackOutput: %@", exception);
return nil;
}
}
@end

View File

@ -1,318 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
/// Incrementally migrates TSAttachments owned by TSMessages to v2 attachments.
public protocol IncrementalMessageTSAttachmentMigrator {
/// - parameter ignorePastFailures: If true, will always run regardless of past failures.
/// If false, will skip running if previous attempts failed.
///
/// Supports task cancellation.
///
/// - Returns
/// True if anything was migrated, false otherwise.
@discardableResult
func runInMainAppUntilFinished(ignorePastFailures: Bool, progress: OWSProgressSink?) async -> Bool
// Returns true if done.
func runNextBatch(logger: TSAttachmentMigrationLogger) async -> Bool
}
public class IncrementalMessageTSAttachmentMigratorImpl: IncrementalMessageTSAttachmentMigrator {
private let appContext: AppContext
private let databaseStorage: SDSDatabaseStorage
private let remoteConfigManager: RemoteConfigManager
private let store: IncrementalTSAttachmentMigrationStore
private let tsAccountManager: TSAccountManager
public init(
appContext: AppContext,
appReadiness: AppReadiness,
databaseStorage: SDSDatabaseStorage,
remoteConfigManager: RemoteConfigManager,
store: IncrementalTSAttachmentMigrationStore,
tsAccountManager: TSAccountManager
) {
self.appContext = appContext
self.databaseStorage = databaseStorage
self.remoteConfigManager = remoteConfigManager
self.store = store
self.tsAccountManager = tsAccountManager
appReadiness.runNowOrWhenAppDidBecomeReadyAsync { [weak self] in
guard let self else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidBecomeActive),
name: .OWSApplicationDidBecomeActive,
object: nil
)
Task { await self.runInMainAppBackground() }
}
}
@objc
private func appDidBecomeActive() {
Task { await self.runInMainAppBackground() }
}
public func runInMainAppUntilFinished(ignorePastFailures: Bool, progress: OWSProgressSink?) async -> Bool {
// We DO NOT check any of the feature flag or remote config break-glass-es here;
// this is used by backups which require the migration to have finished
// and aren't enabled outside internal builds anyway.
if !ignorePastFailures, !store.shouldAttemptMigrationUntilFinished() {
Logger.warn("Skipping migration because of past failed attempts")
return false
}
let state = databaseStorage.read(block: store.getState(tx:))
switch state {
case .finished:
return false
case .unstarted, .started:
Logger.info("Running until finished")
}
var remainingAttachmentCount: UInt64 = 0
var progressSource: OWSProgressSource?
if let progress {
remainingAttachmentCount = databaseStorage.read(block: fetchRemainingTSAttachmentCount(tx:))
if remainingAttachmentCount > 0 {
progressSource = await progress.addSource(
withLabel: "Remaining Interactions",
unitCount: remainingAttachmentCount
)
}
}
store.willAttemptMigrationUntilFinished()
let logger = MainAppMigrationLogger(appContext: appContext, store: store)
var batchCount = 0
var didFinish = false
while !didFinish {
// Run in batches, instead of one big write transaction, so that
// we can commit incremental progress if we are interrupted.
didFinish = await self.runNextBatch(logger: logger)
batchCount += 1
if let progressSource {
let newCount = databaseStorage.read(block: fetchRemainingTSAttachmentCount(tx:))
let diff = remainingAttachmentCount - newCount
remainingAttachmentCount = newCount
if diff > 0 {
progressSource.incrementCompletedUnitCount(by: diff)
}
}
do {
try Task.checkCancellation()
} catch {
Logger.warn("Cancelled; stopping after \(batchCount) batches")
return true
}
}
Logger.info("Ran until finished after \(batchCount) batches")
return true
}
private func fetchRemainingTSAttachmentCount(tx: DBReadTransaction) -> UInt64 {
UInt64(clamping: (try? Int64.fetchOne(
tx.database,
sql: "SELECT COUNT(id) FROM model_TSAttachment;"
)) ?? 0)
}
private let isRunningInMainApp = AtomicBool(false, lock: .init())
private func runInMainAppBackground() async {
guard
BuildFlags.runTSAttachmentMigrationInMainAppBackground,
appContext.isMainAppAndActive,
isRunningInMainApp.tryToSetFlag()
else {
return
}
defer {
isRunningInMainApp.set(false)
}
let state = databaseStorage.read(block: store.getState(tx:))
switch state {
case .unstarted:
Logger.info("Has not started message attachment migration")
case .started:
Logger.info("Partial progress on message attachment migration")
case .finished:
Logger.info("Finished message attachment migration")
return
}
// Fetch remote config for kill switch; if fetch fails use cached local config.
let isAllowedByRemoteConfig: Bool
if tsAccountManager.registrationStateWithMaybeSneakyTransaction.isRegistered {
try? await remoteConfigManager.refreshIfNeeded()
let remoteConfig = remoteConfigManager.currentConfig()
isAllowedByRemoteConfig = remoteConfig.shouldRunTSAttachmentMigrationInMainAppBackground
} else {
// If we aren't registered, we can't fetch a remote config.
// Use default true, even if we have a cached remote config value.
// We want to try running; worst case these deregistered users fail/crash once
// and then are prevented from crashing further by the attempt counting.
isAllowedByRemoteConfig = true
}
guard isAllowedByRemoteConfig else {
Logger.info("Disabled via remote config, stopping")
return
}
if !store.shouldAttemptMigrationUntilFinished() {
Logger.warn("Skipping background migration because of past failed attempts")
return
}
let delayMs = remoteConfigManager.currentConfig().tsAttachmentMigrationBatchDelayMs
store.willAttemptMigrationUntilFinished()
let logger = MainAppMigrationLogger(appContext: appContext, store: store)
var batchCount = 0
var didFinish = false
while !didFinish {
// Add a small delay between each batch to avoid locking the db write queue.
try? await Task.sleep(nanoseconds: delayMs * NSEC_PER_MSEC)
guard appContext.isMainAppAndActive else {
// If the main app goes into the background, we shouldn't be
// grabbing the sql write lock. Stop.
Logger.info("Stopping when backgrounding app after \(batchCount) batches")
if batchCount == 0 {
// If we exit before doing a single batch, don't count it as a failure.
store.didEarlyExitBeforeAttemptingBatch()
}
return
}
// Only migrate one message at a time so we don't hold the write lock
// too long while doing file i/o.
didFinish = await self._runNextBatch(messageBatchSize: 1, logger: logger)
batchCount += 1
}
Logger.info("Finished in main app after \(batchCount) batches")
}
// Returns true if done.
public func runNextBatch(logger: TSAttachmentMigrationLogger) async -> Bool {
return await _runNextBatch(logger: logger)
}
// Returns true if done.
private func _runNextBatch(messageBatchSize: Int = 5, logger: TSAttachmentMigrationLogger) async -> Bool {
typealias Migrator = TSAttachmentMigration.TSMessageMigration
let isDone = await databaseStorage.awaitableWrite { tx in
// First we try to migrate a batch of prepared messages.
let didMigrateBatch = Migrator.completeNextIterativeTSMessageMigrationBatch(
batchSize: messageBatchSize,
logger: logger,
tx: tx
)
if didMigrateBatch {
return false
}
// If no messages are prepared, we try to prepare a batch of messages.
let didPrepareBatch = Migrator.prepareNextIterativeTSMessageMigrationBatch(
logger: logger,
tx: tx
)
if didPrepareBatch {
do {
try self.store.setState(.started, tx: tx)
} catch let error {
logger.didFatalError("\(error)")
owsFail("Failed to write state to db")
}
return false
}
// If there was nothing to migrate and nothing to prepare, wipe the files and finish.
Migrator.cleanUpTSAttachmentFiles()
do {
try self.store.setState(.finished, tx: tx)
} catch let error {
logger.didFatalError("\(error)")
owsFail("Failed to write state to db")
}
return true
}
store.didSucceedMigrationBatch()
return isDone
}
private class MainAppMigrationLogger: TSAttachmentMigrationLogger {
private let appContext: AppContext
private let store: IncrementalTSAttachmentMigrationStore
init(
appContext: AppContext,
store: IncrementalTSAttachmentMigrationStore
) {
self.appContext = appContext
self.store = store
}
func didFatalError(_ logString: String) {
// In this context we don't do anything with errors;
// the owsFail is in the same process.
}
func flagDBCorrupted() {
DatabaseCorruptionState.flagDatabaseAsCorrupted(userDefaults: appContext.appUserDefaults())
}
func checkpoint(_ checkpointString: String) {
store.saveLastCheckpoint(checkpointString)
}
}
}
public class NoOpIncrementalMessageTSAttachmentMigrator: IncrementalMessageTSAttachmentMigrator {
public init() {}
public func runInMainAppUntilFinished(ignorePastFailures: Bool, progress: OWSProgressSink?) async -> Bool {
return false
}
// Returns true if done.
public func runNextBatch(logger: TSAttachmentMigrationLogger) async -> Bool {
return true
}
}
#if TESTABLE_BUILD
public class IncrementalMessageTSAttachmentMigratorMock: IncrementalMessageTSAttachmentMigrator {
public init() {}
public func runInMainAppUntilFinished(ignorePastFailures: Bool, progress: OWSProgressSink?) async -> Bool {
return false
}
// Returns true if done.
public func runNextBatch(logger: TSAttachmentMigrationLogger) async -> Bool {
return true
}
}
#endif

View File

@ -1,75 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public protocol IncrementalMessageTSAttachmentMigratorFactory {
func migrator(
appContext: AppContext,
appReadiness: AppReadiness,
databaseStorage: SDSDatabaseStorage,
remoteConfigManager: RemoteConfigManager,
tsAccountManager: TSAccountManager
) -> IncrementalMessageTSAttachmentMigrator
}
public class IncrementalMessageTSAttachmentMigratorFactoryImpl: IncrementalMessageTSAttachmentMigratorFactory {
private let store: IncrementalTSAttachmentMigrationStore
public init(store: IncrementalTSAttachmentMigrationStore) {
self.store = store
}
public func migrator(
appContext: AppContext,
appReadiness: AppReadiness,
databaseStorage: SDSDatabaseStorage,
remoteConfigManager: RemoteConfigManager,
tsAccountManager: TSAccountManager
) -> IncrementalMessageTSAttachmentMigrator {
return IncrementalMessageTSAttachmentMigratorImpl(
appContext: appContext,
appReadiness: appReadiness,
databaseStorage: databaseStorage,
remoteConfigManager: remoteConfigManager,
store: store,
tsAccountManager: tsAccountManager
)
}
}
public class NoOpIncrementalMessageTSAttachmentMigratorFactory: IncrementalMessageTSAttachmentMigratorFactory {
public init() {}
public func migrator(
appContext: AppContext,
appReadiness: AppReadiness,
databaseStorage: SDSDatabaseStorage,
remoteConfigManager: RemoteConfigManager,
tsAccountManager: TSAccountManager
) -> IncrementalMessageTSAttachmentMigrator {
return NoOpIncrementalMessageTSAttachmentMigrator()
}
}
#if TESTABLE_BUILD
public class IncrementalMessageTSAttachmentMigratorFactoryMock: IncrementalMessageTSAttachmentMigratorFactory {
public init() {}
public func migrator(
appContext: AppContext,
appReadiness: AppReadiness,
databaseStorage: SDSDatabaseStorage,
remoteConfigManager: RemoteConfigManager,
tsAccountManager: TSAccountManager
) -> IncrementalMessageTSAttachmentMigrator {
return IncrementalMessageTSAttachmentMigratorMock()
}
}
#endif

View File

@ -1,139 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public class IncrementalTSAttachmentMigrationStore {
public enum State: Int, Codable {
case unstarted
case started
case finished
static let key = "state"
}
private let userDefaults: UserDefaults
public init(userDefaults: UserDefaults) {
self.userDefaults = userDefaults
}
private let kvStore = KeyValueStore(collection: "IncrementalMessageTSAttachmentMigrator")
public func getState(tx: DBReadTransaction) -> State {
return (try? kvStore.getCodableValue(forKey: State.key, transaction: tx)) ?? .unstarted
}
public func setState(_ state: State, tx: DBWriteTransaction) throws {
try kvStore.setCodable(state, key: State.key, transaction: tx)
}
// MARK: - Errors
/// Value should be incremented when we apply a forward fix to the TSAttachment migration.
/// When this value changes, users who had previously failed to migrate and are now skipping the migration
/// will attempt to migrate once again.
/// v1 = initial launch
/// v2 = bug where we'd count migrations that got interrupted by app termination before finishing one batch
/// as "failed"; we increment the number so they retry now that this is resolved.
/// v3 = a couple failures that mostly look like db corruption; added db corruption checks.
private static let currentMigrationVersion = 3
/// NOTE: in reality one more attempt than this number may happen before we give up. This is because
/// we count an attempt as successful if it completes a single batch; if a subsequent batch fails the attempt
/// was still counted as success so we will try again (with the failing batch now being the first batch) and
/// a second failure in the now-first batch will count as a failed attempt and prevent future attempts.
private static let maxNumAttemptsBeforeSkipping = 2
private static let lastMigrationAttemptVersionKey = "TSAttachmentMigration_lastMigrationAttemptVersionKey"
private static let lastMigrationAttemptDateKey = "TSAttachmentMigration_lastMigrationAttemptDateKey"
private static let migrationIncompleteAttemptCountKey = "TSAttachmentMigration_migrationIncompleteAttemptCountKey"
private static let didReportFailureInUIKey = "TSAttachmentMigration_didReportFailureInUIKey"
public func shouldAttemptMigrationUntilFinished() -> Bool {
let lastAttemptVersion = userDefaults.integer(forKey: Self.lastMigrationAttemptVersionKey)
if lastAttemptVersion != Self.currentMigrationVersion {
return true
}
let lastAttemptDate: Date? = userDefaults.object(forKey: Self.lastMigrationAttemptDateKey) as? Date
if Date().timeIntervalSince((lastAttemptDate ?? .distantPast)) >= .week {
return true
}
let incompleteAttemptCount = userDefaults.integer(forKey: Self.migrationIncompleteAttemptCountKey)
return incompleteAttemptCount < Self.maxNumAttemptsBeforeSkipping
}
public func willAttemptMigrationUntilFinished() {
let lastAttemptVersion = userDefaults.integer(forKey: Self.lastMigrationAttemptVersionKey)
let prevIncompleteAttemptCount: Int
if lastAttemptVersion == Self.currentMigrationVersion {
prevIncompleteAttemptCount = userDefaults.integer(forKey: Self.migrationIncompleteAttemptCountKey)
} else {
prevIncompleteAttemptCount = 0
}
userDefaults.set(Self.currentMigrationVersion, forKey: Self.lastMigrationAttemptVersionKey)
userDefaults.set(Date(), forKey: Self.lastMigrationAttemptDateKey)
userDefaults.set(prevIncompleteAttemptCount + 1, forKey: Self.migrationIncompleteAttemptCountKey)
userDefaults.set(false, forKey: Self.didReportFailureInUIKey)
}
public func didSucceedMigrationBatch() {
userDefaults.set(0, forKey: Self.migrationIncompleteAttemptCountKey)
userDefaults.set(Date(), forKey: Self.lastMigrationAttemptDateKey)
userDefaults.set(false, forKey: Self.didReportFailureInUIKey)
}
public func didEarlyExitBeforeAttemptingBatch() {
let prevIncompleteAttemptCount = userDefaults.integer(forKey: Self.migrationIncompleteAttemptCountKey)
guard prevIncompleteAttemptCount > 0 else {
owsFailDebug("Not marked as making an attempt")
return
}
userDefaults.set(prevIncompleteAttemptCount - 1, forKey: Self.migrationIncompleteAttemptCountKey)
}
public func shouldReportFailureInUI() -> Bool {
if userDefaults.bool(forKey: Self.didReportFailureInUIKey) {
return false
}
return !shouldAttemptMigrationUntilFinished()
}
public func didReportFailureInUI() {
userDefaults.set(true, forKey: Self.didReportFailureInUIKey)
}
// MARK: BGProcessingTask
private static let bgProcessingTaskErrorKey = "TSAttachmentMigration_bgProcessingTaskErrorKey"
private static let hasLoggedBgProcessingTaskErrorKey = "TSAttachmentMigration_hasLoggedBGProcessingTaskErrorKey"
public func bgProcessingTaskDidExperienceError(logString: String) {
userDefaults.set(logString, forKey: Self.bgProcessingTaskErrorKey)
userDefaults.setValue(false, forKey: Self.hasLoggedBgProcessingTaskErrorKey)
}
/// Returns (error string, has been logged before)
public func consumeLastBGProcessingTaskError() -> (String, Bool)? {
let value = userDefaults.string(forKey: Self.bgProcessingTaskErrorKey)
guard let value else { return nil }
let wasLoggedBefore = userDefaults.bool(forKey: Self.hasLoggedBgProcessingTaskErrorKey)
if !wasLoggedBefore {
userDefaults.setValue(true, forKey: Self.hasLoggedBgProcessingTaskErrorKey)
}
return (value, wasLoggedBefore)
}
// MARK: Checkpoints
private static let lastCheckpointKey = "TSAttachmentMigration_lastCheckpointKey"
public func saveLastCheckpoint(_ checkpointString: String) {
userDefaults.set(checkpointString, forKey: Self.lastCheckpointKey)
}
public func getLastCheckpoint() -> String? {
userDefaults.string(forKey: Self.lastCheckpointKey)
}
}

View File

@ -1,693 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVKit
import Foundation
extension TSAttachmentMigration {
struct PendingV2AttachmentFile {
let blurHash: String?
let sha256ContentHash: Data
let encryptedByteCount: UInt32
let unencryptedByteCount: UInt32
let mimeType: String
let encryptionKey: Data
let digestSHA256Ciphertext: Data
let localRelativeFilePath: String
let renderingFlag: TSAttachmentMigration.V2RenderingFlag
let sourceFilename: String?
let validatedContentType: TSAttachmentMigration.V2Attachment.ContentType
let audioDurationSeconds: Double?
let mediaSizePixels: CGSize?
let videoDurationSeconds: Double?
let audioWaveformRelativeFilePath: String?
let videoStillFrameRelativeFilePath: String?
}
class V2AttachmentContentValidator {
// Note that unlike "live" attachment validation which assigns final
// attachment file locations on the fly, the migrations are required
// to "reserve" the final location using a random but persisted UUID.
// This way if the migration is interrupted, any files we managed
// to create before interruption are simply written over instead of
// living forever unreferenced and consuming space.
struct ReservedRelativeFileIds {
let primaryFile: UUID
let audioWaveform: UUID
let videoStillFrame: UUID
}
static func validateContents(
unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
attachmentKey: AttachmentKey? = nil,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
let byteSize: UInt64 = {
return (try? OWSFileSystem.fileSize(of: unencryptedFileUrl)) ?? 0
}()
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeGeneric */ else {
throw AttachmentTooLargeError()
}
let attachmentKey = attachmentKey ?? .generate()
let pendingAttachment = try validateContents(
unencryptedFileUrl: unencryptedFileUrl,
byteSize: Int(byteSize),
reservedFileIds: reservedFileIds,
attachmentKey: attachmentKey,
mimeType: mimeType,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename
)
return pendingAttachment
}
private static func validateContents(
unencryptedFileUrl: URL,
byteSize: Int,
reservedFileIds: ReservedRelativeFileIds,
attachmentKey: AttachmentKey,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
var mimeType = mimeType
let contentTypeResult = try validateContentType(
unencryptedFileUrl: unencryptedFileUrl,
byteSize: byteSize,
reservedFileIds: reservedFileIds,
attachmentKey: attachmentKey,
mimeType: &mimeType
)
return try prepareAttachmentFiles(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
attachmentKey: attachmentKey,
mimeType: mimeType,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename,
contentResult: contentTypeResult
)
}
private static let thumbnailDimensionPointsForQuotedReply: CGFloat = 200
static func prepareQuotedReplyThumbnail(
fromOriginalAttachmentStream stream: TSAttachmentMigration.V1Attachment,
reservedFileIds: ReservedRelativeFileIds,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?
) throws -> TSAttachmentMigration.PendingV2AttachmentFile? {
guard let localFilePath = stream.localFilePath else {
throw OWSAssertionError("Non stream")
}
let originalImage: UIImage
// The thing called "contentType" on TSAttachment is the MIME type.
let contentType = self.rawContentType(mimeType: stream.contentType)
switch contentType {
case .invalid, .audio, .file:
throw OWSAssertionError("Non visual media target")
case .image, .animatedImage:
guard let image = UIImage(contentsOfFile: localFilePath) else {
Logger.error("Unable to read image")
return nil
}
originalImage = image
case .video:
let asset: AVAsset = AVAsset(url: URL(fileURLWithPath: localFilePath))
guard TSAttachmentMigration.OWSMediaUtils.isValidVideo(asset: asset) else {
throw OWSAssertionError("Unable to read video")
}
do {
originalImage = try TSAttachmentMigration.OWSMediaUtils.thumbnail(
forVideo: asset,
maxSizePixels: .square(AttachmentThumbnailQuality.large.thumbnailDimensionPoints())
)
} catch {
Logger.error("Failed to generate video still frame")
return nil
}
}
guard
let resizedImage = TSAttachmentMigration.OWSMediaUtils.resize(
image: originalImage,
maxDimensionPoints: Self.thumbnailDimensionPointsForQuotedReply
),
let imageData = resizedImage.jpegData(compressionQuality: 0.8)
else {
Logger.error("Unable to create thumbnail")
return nil
}
let tmpFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
try imageData.write(to: tmpFile)
let renderingFlagForThumbnail: TSAttachmentMigration.V2RenderingFlag
switch renderingFlag {
case .borderless:
// Preserve borderless flag from the original
renderingFlagForThumbnail = .borderless
case .default, .voiceMessage, .shouldLoop:
// Other cases become default for the still image.
renderingFlagForThumbnail = .default
}
return try Self.validateContents(
unencryptedFileUrl: tmpFile,
reservedFileIds: reservedFileIds,
mimeType: "image/jpeg",
renderingFlag: renderingFlagForThumbnail,
sourceFilename: sourceFilename
)
}
// MARK: Content Type Validation
static let supportedVideoMimeTypes: Set<String> = [
"video/3gpp",
"video/3gpp2",
"video/mp4",
"video/quicktime",
"video/x-m4v",
"video/mpeg",
]
static let supportedAudioMimeTypes: Set<String> = [
"audio/aac",
"audio/x-m4p",
"audio/x-m4b",
"audio/x-m4a",
"audio/wav",
"audio/x-wav",
"audio/x-mpeg",
"audio/mpeg",
"audio/mp4",
"audio/mp3",
"audio/mpeg3",
"audio/x-mp3",
"audio/x-mpeg3",
"audio/aiff",
"audio/x-aiff",
"audio/3gpp2",
"audio/3gpp",
]
static let supportedImageMimeTypes: Set<String> = [
"image/jpeg",
"image/pjpeg",
"image/png",
"image/tiff",
"image/x-tiff",
"image/bmp",
"image/x-windows-bmp",
"image/heic",
"image/heif",
"image/webp",
]
static let supportedDefinitelyAnimatedMimeTypes: Set<String> = [
"image/gif",
"image/apng",
"image/vnd.mozilla.apng",
]
public static let supportedMaybeAnimatedMimeTypes: Set<String> = Set([
"image/webp",
"image/png",
]).union(supportedDefinitelyAnimatedMimeTypes)
static func rawContentType(mimeType: String) -> TSAttachmentMigration.V2Attachment.ContentType {
if Self.supportedVideoMimeTypes.contains(mimeType) {
return .video
} else if Self.supportedAudioMimeTypes.contains(mimeType) {
return .audio
} else if Self.supportedDefinitelyAnimatedMimeTypes.contains(mimeType) {
return .animatedImage
} else if Self.supportedImageMimeTypes.contains(mimeType) {
return .image
} else if Self.supportedMaybeAnimatedMimeTypes.contains(mimeType) {
return .animatedImage
} else {
return .file
}
}
fileprivate struct PendingFile {
let tmpFileUrl: URL
let isTmpFileEncrypted: Bool
let reservedRelativeFilePath: String
init(
tmpFileUrl: URL,
isTmpFileEncrypted: Bool,
reservedRelativeFilePath: String
) {
self.tmpFileUrl = tmpFileUrl
self.isTmpFileEncrypted = isTmpFileEncrypted
self.reservedRelativeFilePath = reservedRelativeFilePath
}
}
private struct ContentTypeResult {
let contentType: TSAttachmentMigration.V2Attachment.ContentType
let audioDurationSeconds: Double?
let mediaSizePixels: CGSize?
let videoDurationSeconds: Double?
let blurHash: String?
let audioWaveformFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile?
let videoStillFrameFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile?
}
private static func validateContentType(
unencryptedFileUrl: URL,
byteSize: Int,
reservedFileIds: ReservedRelativeFileIds,
attachmentKey: AttachmentKey,
mimeType: inout String
) throws -> ContentTypeResult {
let invalidResult = ContentTypeResult(
contentType: .invalid,
audioDurationSeconds: nil,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
switch rawContentType(mimeType: mimeType) {
case .invalid:
return invalidResult
case .file:
return ContentTypeResult(
contentType: .file,
audioDurationSeconds: nil,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
case .image:
guard byteSize < 8 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeImage */ else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .animatedImage:
guard byteSize < 25 * 1024 * 1024 /* SignalAttachment.kMaxFileSizeAnimatedImage */ else {
throw AttachmentTooLargeError()
}
return try validateImageContentType(
unencryptedFileUrl,
mimeType: &mimeType
) ?? invalidResult
case .video:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeVideo */ else {
throw AttachmentTooLargeError()
}
return try validateVideoContentType(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
attachmentKey: attachmentKey,
) ?? invalidResult
case .audio:
guard byteSize < 95 * 1000 * 1000 /* SignalAttachment.kMaxFileSizeAudio */ else {
throw AttachmentTooLargeError()
}
return try validateAudioContentType(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
attachmentKey: attachmentKey,
) ?? invalidResult
}
}
// MARK: Image/Animated
// Includes static and animated image validation.
private static func validateImageContentType(
_ unencryptedFileUrl: URL,
mimeType: inout String
) throws -> ContentTypeResult? {
let imageSource: TSAttachmentMigration.OWSImageSource = try {
do {
return try TSAttachmentMigration.OWSImageSource(fileUrl: unencryptedFileUrl)
} catch {
var errorString = "\(error)"
errorString = errorString.replacingOccurrences(of: "/Attachments/", with: "/[attachment_dir]/")
errorString = errorString.replacingOccurrences(of: unencryptedFileUrl.lastPathComponent, with: "xxxx")
throw OWSAssertionError("Failed to open file handle image source \(errorString)")
}
}()
guard let imageMetadata = imageSource.imageMetadata(
mimeTypeForValidation: mimeType
) else {
return nil
}
guard imageMetadata.isValid else {
return nil
}
let pixelSize = imageMetadata.pixelSize
let blurHash: String? = {
guard let image = UIImage(contentsOfFile: unencryptedFileUrl.path) else {
return nil
}
return try? BlurHash.computeBlurHashSync(for: image)
}()
let contentType: TSAttachmentMigration.V2Attachment.ContentType
if imageMetadata.isAnimated {
contentType = .animatedImage
} else {
contentType = .image
}
return ContentTypeResult(
contentType: contentType,
audioDurationSeconds: nil,
mediaSizePixels: pixelSize,
videoDurationSeconds: nil,
blurHash: blurHash,
audioWaveformFile: nil,
videoStillFrameFile: nil
)
}
// MARK: Video
public class AttachmentTooLargeError: Error {}
private static func validateVideoContentType(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
attachmentKey: AttachmentKey,
) throws -> ContentTypeResult? {
let asset: AVAsset = {
return AVAsset(url: unencryptedFileUrl)
}()
guard TSAttachmentMigration.OWSMediaUtils.isValidVideo(asset: asset) else {
return nil
}
let thumbnailImage = try? TSAttachmentMigration.OWSMediaUtils.thumbnail(
forVideo: asset,
maxSizePixels: .square(AttachmentThumbnailQuality.large.thumbnailDimensionPoints())
)
guard let thumbnailImage else {
return nil
}
let stillFrameFile: TSAttachmentMigration.V2AttachmentContentValidator.PendingFile? = try thumbnailImage
// Don't compress; we already size-limited this thumbnail, it already has whatever
// compression applied to the source video, and we want a high fidelity still frame.
.jpegData(compressionQuality: 1)
.map { thumbnailData in
let thumbnailTmpFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let (encryptedThumbnail, _) = try Cryptography.encrypt(thumbnailData, attachmentKey: attachmentKey)
try encryptedThumbnail.write(to: thumbnailTmpFile)
return TSAttachmentMigration.V2AttachmentContentValidator.PendingFile(
tmpFileUrl: thumbnailTmpFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.videoStillFrame
)
)
}
let blurHash = try? BlurHash.computeBlurHashSync(for: thumbnailImage)
let duration = asset.duration.seconds
// We have historically used the size of the still frame as the video size.
let pixelSize = thumbnailImage.pixelSize
return ContentTypeResult(
contentType: .video,
audioDurationSeconds: nil,
mediaSizePixels: pixelSize,
videoDurationSeconds: duration,
blurHash: blurHash,
audioWaveformFile: nil,
videoStillFrameFile: stillFrameFile
)
}
// MARK: Audio
private static func validateAudioContentType(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
attachmentKey: AttachmentKey,
) throws -> ContentTypeResult? {
let duration = try computeAudioDuration(unencryptedFileUrl, mimeType: mimeType)
guard let duration else {
Logger.error("Unable to compute duration, treating audio as invalid file")
return nil
}
// Don't require the waveform file.
let waveformFile = try? self.createAudioWaveform(
unencryptedFileUrl,
reservedFileIds: reservedFileIds,
mimeType: mimeType,
attachmentKey: attachmentKey,
)
return ContentTypeResult(
contentType: .audio,
audioDurationSeconds: duration,
mediaSizePixels: nil,
videoDurationSeconds: nil,
blurHash: nil,
audioWaveformFile: waveformFile,
videoStillFrameFile: nil
)
}
private static func computeAudioDuration(_ unencryptedFileUrl: URL, mimeType: String) throws -> TimeInterval? {
do {
let player = try AVAudioPlayer(contentsOf: unencryptedFileUrl)
player.prepareToPlay()
return player.duration
} catch {
let pathExtension = unencryptedFileUrl.pathExtension
if
pathExtension == "aac"
|| mimeType == "audio/aac"
|| mimeType == "audio/x-aac"
{
// AVAudioPlayer can't handle aac file extensions, but _should_ work
// if we just change the extension.
Logger.info("Failed aac file, retrying as m4a")
let newTmpURL = OWSFileSystem.temporaryFileUrl(
fileExtension: "m4a",
isAvailableWhileDeviceLocked: true
)
do {
try FileManager.default.copyItem(at: unencryptedFileUrl, to: newTmpURL)
} catch {
throw OWSAssertionError("Failed to copy attachment file")
}
let player: AVAudioPlayer
do {
player = try AVAudioPlayer(contentsOf: newTmpURL)
} catch {
Logger.error("Failed to read aac file after reapplying extension")
return nil
}
player.prepareToPlay()
let duration = player.duration
try FileManager.default.removeItem(at: newTmpURL)
return duration
}
Logger.error("Failed reading audio file, mimeType: \(mimeType) ext: \(pathExtension)")
return nil
}
}
private enum AudioWaveformFile {
case unencrypted(URL)
case encrypted(URL, encryptionKey: Data)
}
private static func createAudioWaveform(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
mimeType: String,
attachmentKey: AttachmentKey,
) throws -> TSAttachmentMigration.V2AttachmentContentValidator.PendingFile {
let waveform: TSAttachmentMigration.AudioWaveform = try TSAttachmentMigration.AudioWaveformManager
.buildAudioWaveForm(unencryptedFilePath: unencryptedFileUrl.path)
let outputWaveformFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let waveformData = try waveform.archive()
let (encryptedWaveform, _) = try Cryptography.encrypt(waveformData, attachmentKey: attachmentKey)
try encryptedWaveform.write(to: outputWaveformFile, options: .atomicWrite)
return .init(
tmpFileUrl: outputWaveformFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.audioWaveform
)
)
}
// MARK: - File Preparation
private static func prepareAttachmentFiles(
_ unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
attachmentKey: AttachmentKey,
mimeType: String,
renderingFlag: TSAttachmentMigration.V2RenderingFlag,
sourceFilename: String?,
contentResult: ContentTypeResult
) throws -> TSAttachmentMigration.PendingV2AttachmentFile {
let primaryFilePlaintextHash = try computePlaintextHash(unencryptedFileUrl: unencryptedFileUrl)
// First encrypt the files that need encrypting.
let (primaryPendingFile, primaryFileMetadata) = try encryptPrimaryFile(
unencryptedFileUrl: unencryptedFileUrl,
reservedFileIds: reservedFileIds,
attachmentKey: attachmentKey,
)
let primaryFileDigest = primaryFileMetadata.digest
let primaryPlaintextLength = UInt32(exactly: primaryFileMetadata.plaintextLength)
guard let primaryPlaintextLength else {
throw OWSAssertionError("File too large")
}
let primaryEncryptedLength = UInt32(exactly: try OWSFileSystem.fileSize(of: primaryPendingFile.tmpFileUrl))
guard let primaryEncryptedLength else {
throw OWSAssertionError("file too large")
}
let audioWaveformFile = try contentResult.audioWaveformFile?.encryptFileIfNeeded(
attachmentKey: attachmentKey,
)
let videoStillFrameFile = try contentResult.videoStillFrameFile?.encryptFileIfNeeded(
attachmentKey: attachmentKey,
)
// Now we can copy files.
for pendingFile in [primaryPendingFile, audioWaveformFile, videoStillFrameFile].compacted() {
let destinationUrl = TSAttachmentMigration.V2Attachment.absoluteAttachmentFileURL(
relativeFilePath: pendingFile.reservedRelativeFilePath
)
guard OWSFileSystem.ensureDirectoryExists(destinationUrl.deletingLastPathComponent().path) else {
throw OWSAssertionError("Unable to create directory")
}
if OWSFileSystem.fileOrFolderExists(url: destinationUrl) {
// If something is at our reserved (random) location, since collisions are absurdly
// unlikely, it must mean we previously created the file at the reserved location
// but were interrupted. Delete what was there and keep going.
try OWSFileSystem.deleteFile(url: destinationUrl)
}
try OWSFileSystem.moveFile(
from: pendingFile.tmpFileUrl,
to: destinationUrl
)
}
return TSAttachmentMigration.PendingV2AttachmentFile(
blurHash: contentResult.blurHash,
sha256ContentHash: primaryFilePlaintextHash,
encryptedByteCount: primaryEncryptedLength,
unencryptedByteCount: primaryPlaintextLength,
mimeType: mimeType,
encryptionKey: attachmentKey.combinedKey,
digestSHA256Ciphertext: primaryFileDigest,
localRelativeFilePath: primaryPendingFile.reservedRelativeFilePath,
renderingFlag: renderingFlag,
sourceFilename: sourceFilename,
validatedContentType: contentResult.contentType,
audioDurationSeconds: contentResult.audioDurationSeconds,
mediaSizePixels: contentResult.mediaSizePixels,
videoDurationSeconds: contentResult.videoDurationSeconds,
audioWaveformRelativeFilePath: contentResult.audioWaveformFile?.reservedRelativeFilePath,
videoStillFrameRelativeFilePath: contentResult.videoStillFrameFile?.reservedRelativeFilePath
)
}
// MARK: - Encryption
private static func computePlaintextHash(unencryptedFileUrl: URL) throws -> Data {
return try Cryptography.computeSHA256DigestOfFile(at: unencryptedFileUrl)
}
private static func encryptPrimaryFile(
unencryptedFileUrl: URL,
reservedFileIds: ReservedRelativeFileIds,
attachmentKey: AttachmentKey,
) throws -> (TSAttachmentMigration.V2AttachmentContentValidator.PendingFile, EncryptionMetadata) {
let outputFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
let encryptionMetadata = try Cryptography.encryptAttachment(
at: unencryptedFileUrl,
output: outputFile,
attachmentKey: attachmentKey,
)
return (
TSAttachmentMigration.V2AttachmentContentValidator.PendingFile(
tmpFileUrl: outputFile,
isTmpFileEncrypted: true,
reservedRelativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileIds.primaryFile
)
),
encryptionMetadata
)
}
}
}
extension TSAttachmentMigration.V2AttachmentContentValidator.PendingFile {
fileprivate func encryptFileIfNeeded(
attachmentKey: AttachmentKey,
) throws -> Self {
if isTmpFileEncrypted {
return self
}
let outputFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
// Encrypt _without_ custom padding; we never send these files
// and just use them locally, so no need for custom padding
// that later requires out-of-band plaintext length tracking
// so we can trim the custom padding at read time.
_ = try Cryptography.encryptFile(
at: tmpFileUrl,
output: outputFile,
attachmentKey: attachmentKey,
)
return Self(
tmpFileUrl: outputFile,
isTmpFileEncrypted: true,
// Preserve the reserved file path; this is already
// on the ContentType enum and musn't be changed.
reservedRelativeFilePath: self.reservedRelativeFilePath
)
}
}

View File

@ -1,320 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Accelerate
import AVFoundation
import Foundation
extension TSAttachmentMigration {
struct AudioWaveform {
let decibelSamples: [Float]
func archive() throws -> Data {
return try NSKeyedArchiver.archivedData(withRootObject: decibelSamples, requiringSecureCoding: false)
}
}
class AudioWaveformManager {
static func buildAudioWaveForm(
unencryptedFilePath: String
) throws -> TSAttachmentMigration.AudioWaveform {
let asset: AVAsset = try assetFromUnencryptedAudioFile(atAudioPath: unencryptedFilePath)
guard asset.isReadable else {
throw OWSAssertionError("unexpectedly encountered unreadable audio file.")
}
guard CMTimeGetSeconds(asset.duration) <= Self.maximumDuration else {
throw OWSAssertionError("Audio too long")
}
return try sampleWaveform(asset: asset, filePath: unencryptedFilePath)
}
private static func assetFromUnencryptedAudioFile(
atAudioPath audioPath: String
) throws -> AVAsset {
let audioUrl = URL(fileURLWithPath: audioPath)
var asset = AVURLAsset(url: audioUrl)
if !asset.isReadable {
if let extensionOverride = Self.alternativeAudioFileExtension(fileExtension: audioUrl.pathExtension) {
let symlinkPath = OWSFileSystem.temporaryFilePath(
fileExtension: extensionOverride,
isAvailableWhileDeviceLocked: true
)
do {
try FileManager.default.createSymbolicLink(
atPath: symlinkPath,
withDestinationPath: audioPath
)
} catch {
throw OWSAssertionError("Failed to create symlink")
}
asset = AVURLAsset(url: URL(fileURLWithPath: symlinkPath))
}
}
return asset
}
private static func alternativeAudioFileExtension(fileExtension: String) -> String? {
// In some cases, Android sends audio messages with the "audio/mpeg" mime type. This
// makes our choice of file extension ambiguous`.mp3` or `.m4a`? AVFoundation uses the
// extension to read the file, and if the extension is wrong, it won't be readable.
//
// We "lie" about the extension to generate the waveform so that AVFoundation may read
// it. This is brittle but necessary to work around the buggy marriage of Android's
// content type and AVFoundation's behavior.
//
// Note that we probably still want this code even if Android updates theirs, because
// iOS users might have existing attachments.
//
// See:
// <https://github.com/signalapp/Signal-iOS/issues/3590>.
switch fileExtension {
case "m4a": return "aac"
case "mp3": return "m4a"
default: return nil
}
}
// MARK: - Sampling
/// The maximum duration asset that we will display waveforms for.
/// It's too intensive to sample a waveform for really long audio files.
private static let maximumDuration: TimeInterval = 15 * .minute
private static let sampleCount = 100
private static func sampleWaveform(asset: AVAsset, filePath: String) throws -> TSAttachmentMigration.AudioWaveform {
let assetReader = try AVAssetReader(asset: asset)
// We just draw the waveform based on the first track.
guard let audioTrack = assetReader.asset.tracks.first else {
throw OWSAssertionError("audio file has no tracks")
}
let lastAttemptedFilePathKey = "TSAttachmentMigrationLastAudioWaveformAttempt"
let lastAttemptedFilePath = UserDefaults.standard.string(forKey: lastAttemptedFilePathKey)
if lastAttemptedFilePath == filePath {
// Previously tried to open an AVAssetReaderTrackOutput but crashed.
// Throw an error so we skip this audio file; treat it as corrupted.
throw OWSAssertionError("Unable to generate AVAssetReaderTrackOutput on previous run")
}
UserDefaults.standard.setValue(filePath, forKey: lastAttemptedFilePathKey)
let trackOutput = AVAssetReaderTrackOutputWrapper.safeAssetReaderTrackOutput(
with: audioTrack,
outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsBigEndianKey: false,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsNonInterleaved: false
]
)
UserDefaults.standard.removeObject(forKey: lastAttemptedFilePathKey)
guard let trackOutput else {
throw OWSAssertionError("unable to generate audio track")
}
assetReader.add(trackOutput)
let decibelSamples = try readDecibels(from: assetReader)
return TSAttachmentMigration.AudioWaveform(decibelSamples: decibelSamples)
}
private static func readDecibels(from assetReader: AVAssetReader) throws -> [Float] {
let sampler = AudioWaveformSampler(
inputCount: sampleCount(from: assetReader),
outputCount: Self.sampleCount
)
assetReader.startReading()
while assetReader.status == .reading {
guard let trackOutput = assetReader.outputs.first else {
throw OWSAssertionError("track output unexpectedly missing")
}
// Process any newly read data.
guard
let nextSampleBuffer = trackOutput.copyNextSampleBuffer(),
let blockBuffer = CMSampleBufferGetDataBuffer(nextSampleBuffer)
else {
// There is no more data to read, break
break
}
var lengthAtOffset = 0
var dataPointer: UnsafeMutablePointer<Int8>?
let result = CMBlockBufferGetDataPointer(
blockBuffer,
atOffset: 0,
lengthAtOffsetOut: &lengthAtOffset,
totalLengthOut: nil,
dataPointerOut: &dataPointer
)
guard result == kCMBlockBufferNoErr else {
owsFailDebug("track data unexpectedly inaccessible")
throw AudioWaveformError.invalidAudioFile
}
let bufferPointer = UnsafeBufferPointer(start: dataPointer, count: lengthAtOffset)
bufferPointer.withMemoryRebound(to: Int16.self) { sampler.update($0) }
CMSampleBufferInvalidate(nextSampleBuffer)
}
return sampler.finalize()
}
private static func sampleCount(from assetReader: AVAssetReader) -> Int {
let samplesPerChannel = Int(assetReader.asset.duration.value)
// We will read in the samples from each channel, interleaved since
// we only draw one waveform. This gives us an average of the channels
// if it is, for example, a stereo audio file.
return samplesPerChannel * channelCount(from: assetReader)
}
private static func channelCount(from assetReader: AVAssetReader) -> Int {
guard
let output = assetReader.outputs.first as? AVAssetReaderTrackOutput,
let formatDescriptions = output.track.formatDescriptions as? [CMFormatDescription]
else {
return 0
}
var channelCount = 0
for description in formatDescriptions {
guard let basicDescription = CMAudioFormatDescriptionGetStreamBasicDescription(description) else {
continue
}
channelCount = Int(basicDescription.pointee.mChannelsPerFrame)
}
return channelCount
}
}
private class AudioWaveformSampler {
private static let silenceThreshold: Float = -50
private let inputCount: Int
private let outputCount: Int
/// The number of input samples that feed each output sample (rounded down).
private let segmentLength: Int
/// The number of samples that don't evenly divide into `outputCount`. These
/// extra samples are spread across the output samples.
private let segmentRemainder: Int
/// The number of samples in this segment. Either `segmentLength` or
/// `segmentLength + 1`.
private var currentSegmentCount: Int
/// The number of samples remaining in this segment.
private var currentSegmentRemainingCount: Int
/// Tracks the cumulative average when a segment spans multiple batches.
private var currentSegmentAverage: Float
/// Tracks when a segment needs an extra sample (because outputCount may not
/// evenly divide inputCount).
private var overflowCounter: Int
private var buffer = [Float]()
private var output = [Float]()
init(inputCount: Int, outputCount: Int) {
self.inputCount = inputCount
self.outputCount = outputCount
if inputCount < outputCount {
// If we don't have enough samples, just use every sample that's provided.
// This will result in fewer than outputCount samples, but that is fine.
(self.segmentLength, self.segmentRemainder) = (1, 0)
} else {
(self.segmentLength, self.segmentRemainder) = inputCount.quotientAndRemainder(dividingBy: outputCount)
}
self.currentSegmentAverage = 0
// The first segment is always segmentLength because segmentRemainder is
// less than outputCount (it's the remainder when dividing by outputCount).
self.currentSegmentCount = self.segmentLength
self.currentSegmentRemainingCount = self.segmentLength
self.overflowCounter = self.outputCount - self.segmentRemainder
}
func update(_ samples: UnsafeBufferPointer<Int16>) {
let sampleCount = samples.count
if self.buffer.count < sampleCount {
self.buffer.append(contentsOf: Array(repeating: 0, count: sampleCount - self.buffer.count))
}
// convert UInt16 amplitudes to Float representation
vDSP_vflt16(samples.baseAddress!, 1, &self.buffer, 1, vDSP_Length(sampleCount))
// take the absolute amplitude value
vDSP_vabs(self.buffer, 1, &self.buffer, 1, vDSP_Length(sampleCount))
// convert to dB
// maximum amplitude storable in Int16 = 0 dB (loudest)
// (remember decibels are often negative)
var zeroDecibelEquivalent: Float = Float(Int16.max)
vDSP_vdbcon(self.buffer, 1, &zeroDecibelEquivalent, &self.buffer, 1, vDSP_Length(sampleCount), 1)
// clip between loudest + quietest
var loudestClipValue: Float = 0.0
var quietestClipValue = AudioWaveformSampler.silenceThreshold
vDSP_vclip(self.buffer, 1, &quietestClipValue, &loudestClipValue, &self.buffer, 1, vDSP_Length(sampleCount))
self.reduce(sampleCount: sampleCount)
}
private func reduce(sampleCount: Int) {
self.buffer.withUnsafeBufferPointer { bufferPtr in
var remainingCount = sampleCount
while remainingCount > 0 {
let chunkCount = min(remainingCount, self.currentSegmentRemainingCount)
assert(chunkCount > 0) // because currentSegmentRemainingCount starts > 0 and is checked on each iteration
var chunkAverage: Float = 0
vDSP_meanv(bufferPtr.baseAddress!.advanced(by: sampleCount - remainingCount), 1, &chunkAverage, vDSP_Length(chunkCount))
remainingCount -= chunkCount
self.currentSegmentRemainingCount -= chunkCount
// Add the new average to the running average for this segment.
let totalChunkCount = self.currentSegmentCount - self.currentSegmentRemainingCount
assert(totalChunkCount > 0) // because chunkCount > 0
let newChunkWeight = Float(chunkCount) / Float(totalChunkCount)
let oldChunkWeight = 1 - newChunkWeight
self.currentSegmentAverage *= oldChunkWeight
self.currentSegmentAverage += chunkAverage * newChunkWeight
// If we reached the end of the chunk, add it to the output.
if self.currentSegmentRemainingCount <= 0 {
self.output.append(self.currentSegmentAverage)
self.currentSegmentAverage = 0 // technically redundant
self.currentSegmentCount = self.segmentLength
self.overflowCounter -= self.segmentRemainder
if self.overflowCounter <= 0 {
self.currentSegmentCount += 1
self.overflowCounter += self.segmentLength
}
self.currentSegmentRemainingCount = self.currentSegmentCount
}
}
}
}
func finalize() -> [Float] {
assert(self.output.count <= self.outputCount)
return self.output
}
}
}

View File

@ -1,119 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
extension TSAttachmentMigration {
enum ImageFormat {
case unknown
case png
case gif
case tiff
case jpeg
case bmp
case webp
case heic
case heif
var mimeType: String? {
switch self {
case .png:
return "image/png"
case .gif:
return "image/gif"
case .tiff:
return "image/tiff"
case .jpeg:
return "image/jpeg"
case .bmp:
return "image/bmp"
case .webp:
return "image/webp"
case .heic:
return "image/heic"
case .heif:
return "image/heif"
case .unknown:
return nil
}
}
func isValid(source: TSAttachmentMigration.OWSImageSource) -> Bool {
switch self {
case .unknown:
return false
case .png, .gif, .tiff, .jpeg, .bmp, .webp, .heic, .heif:
return true
}
}
func isValid(mimeType: String?) -> Bool {
owsAssertDebug(!(mimeType?.isEmpty ?? true))
switch self {
case .unknown:
return false
case .png:
guard let mimeType else { return true }
return (mimeType.caseInsensitiveCompare("image/png") == .orderedSame ||
mimeType.caseInsensitiveCompare("image/apng") == .orderedSame ||
mimeType.caseInsensitiveCompare("image/vnd.mozilla.apng") == .orderedSame)
case .gif:
guard let mimeType else { return true }
return mimeType.caseInsensitiveCompare("image/gif") == .orderedSame
case .tiff:
guard let mimeType else { return true }
return (mimeType.caseInsensitiveCompare("image/tiff") == .orderedSame ||
mimeType.caseInsensitiveCompare("image/x-tiff") == .orderedSame)
case .jpeg:
guard let mimeType else { return true }
return mimeType.caseInsensitiveCompare("image/jpeg") == .orderedSame
case .bmp:
guard let mimeType else { return true }
return (mimeType.caseInsensitiveCompare("image/bmp") == .orderedSame ||
mimeType.caseInsensitiveCompare("image/x-windows-bmp") == .orderedSame)
case .webp:
guard let mimeType else { return true }
return mimeType.caseInsensitiveCompare("image/webp") == .orderedSame
case .heic:
guard let mimeType else { return true }
return mimeType.caseInsensitiveCompare("image/heic") == .orderedSame
case .heif:
guard let mimeType else { return true }
return mimeType.caseInsensitiveCompare("image/heif") == .orderedSame
}
}
}
struct ImageMetadata {
let isValid: Bool
let imageFormat: ImageFormat
let pixelSize: CGSize
let hasAlpha: Bool
let isAnimated: Bool
static func invalid() -> Self {
.init(isValid: false, imageFormat: .unknown, pixelSize: .zero, hasAlpha: false, isAnimated: false)
}
var mimeType: String? {
imageFormat.mimeType
}
var fileExtension: String? {
guard let mimeType else {
return nil
}
return MimeTypeUtil.fileExtensionForMimeType(mimeType)
}
}
internal struct WebpMetadata {
let isValid: Bool
let canvasWidth: UInt32
let canvasHeight: UInt32
let frameCount: UInt32
}
}

View File

@ -1,564 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import libwebp
// MARK: - PNG Chunker
extension TSAttachmentMigration {
/// Helps you iterate over PNG chunks from raw data.
///
/// let chunker = try PngChunker(data: myPngData)
/// while let chunk = try chunker.next() {
/// let type = String(data: chunk.type, encoding: .ascii)!
/// print("Found a chunk of type \(type)")
/// }
///
/// Useful for low-level handling of PNGs, not image processing.
///
/// Quick background on PNGs: PNG files always start with the same 8
/// bytes (the "PNG signature") and then contain several chunks. Chunks
/// have a type (like `IHDR` for the image metadata header) and 0 or
/// more bytes of chunk-specific data. Chunks also have two computable
/// fields: the length of the data and a checksum.
///
/// For more, see the ["Chunk layout" section][0] of the PNG spec.
///
/// [0]: https://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-layout
fileprivate class PngChunker {
/// The PNG signature, lifted from [the spec][1].
/// [1]: https://www.w3.org/TR/2003/REC-PNG-20031110/#5PNG-file-signature
fileprivate static let pngSignature = Data([137, 80, 78, 71, 13, 10, 26, 10])
/// The smallest possible PNG size is a well-compressed 1x1 image.
///
/// 8 bytes for the PNG signature,
/// plus 25 bytes for the IHDR chunk (12 for metadata + 13 for data),
/// plus 22 bytes for the IDAT chunk (12 for metadata + 10 for 1 black pixel),
/// plus 12 bytes for the IEND chunk (12 for metadata, no data),
/// = 67 bytes.
private static let smallestPossiblePngSize = 67
private let pngSource: TSAttachmentMigration.OWSImageSource
/// The current index we're looking at. If nil, we're done looking at the data.
private var cursor: Int?
/// Initialize a PNG chunker.
/// - Parameter imageSource: Source for a PNG.
fileprivate init(source: TSAttachmentMigration.OWSImageSource) throws {
guard source.byteLength >= Self.smallestPossiblePngSize else {
throw OWSAssertionError("png too small")
}
let prefix = try? source.readData(byteOffset: 0, byteLength: Self.pngSignature.count)
guard prefix == Self.pngSignature else {
throw OWSAssertionError("File does not start with png signature")
}
pngSource = source
cursor = Self.pngSignature.count
}
/// Get the next PNG chunk.
/// - Returns: The next chunk, or `nil` if the end of the data has been reached.
fileprivate func next() throws -> TSAttachmentMigration.PngChunker.Chunk? {
guard var cursor = cursor, cursor < pngSource.byteLength else {
return nil
}
// Checks that there's enough space for the length (4 bytes) and the type (4 bytes).
guard cursor + 8 <= pngSource.byteLength else {
self.cursor = nil
throw OWSAssertionError("Ended unexpectedly")
}
let lengthBytes = try pngSource.readData(byteOffset: cursor, byteLength: 4)
let length = try Self.asPngUInt32(lengthBytes)
cursor += 4
var expectedCrc = CRC32()
let type = try pngSource.readData(byteOffset: cursor, byteLength: 4)
guard Self.isValidPngType(type) else {
self.cursor = nil
throw OWSAssertionError("Invalid chunk type")
}
expectedCrc = expectedCrc.update(with: type)
cursor += 4
// Checks that there's enough space for the data (N bytes) and the CRC (4 bytes).
let lengthAsInt = Int(length)
guard cursor + lengthAsInt + 4 <= pngSource.byteLength else {
self.cursor = nil
throw OWSAssertionError("Ended unexpectedly")
}
let data = try pngSource.readData(byteOffset: cursor, byteLength: lengthAsInt)
expectedCrc = expectedCrc.update(with: data)
cursor += lengthAsInt
let crcBytes = try pngSource.readData(byteOffset: cursor, byteLength: 4)
let actualCrc = try Self.asPngUInt32(crcBytes)
cursor += 4
guard actualCrc == expectedCrc.value else {
self.cursor = nil
throw OWSAssertionError("Invalid checksum")
}
self.cursor = cursor
return Chunk(
lengthBytes: lengthBytes,
type: type,
data: data,
crcBytes: crcBytes
)
}
// MARK: - Chunk
/// A single PNG chunk. Holds the length, type, data, and CRC checksum.
///
/// For details, see the ["Chunk layout" section][0] of the PNG spec.
///
/// [0]: https://www.w3.org/TR/2003/REC-PNG-20031110/#5Chunk-layout
fileprivate struct Chunk {
/// The chunk data's length, encoded as a PNG 32-bit big endian number.
let lengthBytes: Data
/// The chunk's type, as raw data.
///
/// You may wish to convert this to a string. This is just a normal ASCII conversion:
///
/// let typeString = String(data: myChunk.type, encoding: .ascii)
let type: Data
/// The chunk's data.
let data: Data
/// The chunk's CRC32 code, encoded as a PNG 32-bit big endian number.
let crcBytes: Data
/// Get all the bytes for this chunk.
///
/// Includes all four sections: the length, type, data, and checksum.
///
/// - Returns: The full chunk in bytes.
func allBytes() -> Data {
lengthBytes + type + data + crcBytes
}
}
}
}
extension TSAttachmentMigration.PngChunker {
static func asPngUInt32(_ data: Data) throws -> UInt32 {
var result: UInt32 = 0
for (i, byte) in data.reversed().enumerated() {
result += UInt32(byte) * (1 << (8 * i))
}
return result
}
static func isValidPngType(_ data: Data) -> Bool {
guard data.count == 4 else { return false }
func isAsciiLetter(_ byte: UInt8) -> Bool {
(65...90).contains(byte) || (97...122).contains(byte)
}
return data.allSatisfy(isAsciiLetter)
}
}
// MARK: - Image Validator
extension TSAttachmentMigration {
struct OWSImageSource {
let fileHandle: FileHandle
let byteLength: Int
init(fileUrl: URL) throws {
self.byteLength = Int((try? OWSFileSystem.fileSize(of: fileUrl)) ?? 0)
self.fileHandle = try FileHandle(forReadingFrom: fileUrl)
}
func readData(byteOffset: Int, byteLength: Int) throws -> Data {
if try fileHandle.offset() != byteOffset {
fileHandle.seek(toFileOffset: UInt64(byteOffset))
}
return try fileHandle.read(upToCount: byteLength) ?? Data()
}
func readIntoMemory() throws -> Data {
if try fileHandle.offset() != 0 {
fileHandle.seek(toFileOffset: 0)
}
return try fileHandle.readToEnd() ?? Data()
}
// Class-bound wrapper around FileHandle
class FileHandleWrapper {
let fileHandle: FileHandle
init(_ fileHandle: FileHandle) {
self.fileHandle = fileHandle
}
}
func cgImageSource() throws -> CGImageSource? {
let fileHandle = FileHandleWrapper(fileHandle)
var callbacks = CGDataProviderDirectCallbacks(
version: 0,
getBytePointer: nil,
releaseBytePointer: nil,
getBytesAtPosition: { info, buffer, offset, byteCount in
guard
let unmanagedFileHandle = info?.assumingMemoryBound(
to: Unmanaged<FileHandleWrapper>.self
).pointee
else {
return 0
}
let fileHandle = unmanagedFileHandle.takeUnretainedValue().fileHandle
do {
if offset != (try fileHandle.offset()) {
try fileHandle.seek(toOffset: UInt64(offset))
}
let data = try fileHandle.read(upToCount: byteCount) ?? Data()
data.withUnsafeBytes { bytes in
buffer.copyMemory(from: bytes.baseAddress!, byteCount: bytes.count)
}
return data.count
} catch {
return 0
}
},
releaseInfo: { info in
guard
let unmanagedFileHandle = info?.assumingMemoryBound(
to: Unmanaged<FileHandleWrapper>.self
).pointee
else {
return
}
unmanagedFileHandle.release()
}
)
var unmanagedFileHandle = Unmanaged.passRetained(fileHandle)
guard let dataProvider = CGDataProvider(
directInfo: &unmanagedFileHandle,
size: Int64(byteLength),
callbacks: &callbacks
) else {
throw OWSAssertionError("Failed to create data provider")
}
return CGImageSourceCreateWithDataProvider(dataProvider, nil)
}
fileprivate static func ows_isValidImage(dimension imageSize: CGSize, depthBytes: CGFloat, isAnimated: Bool) -> Bool {
if imageSize.width < 1 || imageSize.height < 1 || depthBytes < 1 {
// Invalid metadata.
return false
}
// We only support (A)RGB and (A)Grayscale, so worst case is 4.
let worstCaseComponentsPerPixel = CGFloat(4)
let bytesPerPixel = worstCaseComponentsPerPixel * depthBytes
let expectedBytesPerPixel: CGFloat = 4
let maxValidImageDimension: CGFloat = CGFloat(isAnimated ? TSAttachmentMigration.kMaxAnimatedImageDimensions : TSAttachmentMigration.kMaxStillImageDimensions)
let maxBytes = maxValidImageDimension * maxValidImageDimension * expectedBytesPerPixel
let actualBytes = imageSize.width * imageSize.height * bytesPerPixel
if actualBytes > maxBytes {
Logger.warn("invalid dimensions width: \(imageSize.width), height \(imageSize.height), bytesPerPixel: \(bytesPerPixel)")
return false
}
return true
}
fileprivate func ows_guessHighEfficiencyImageFormat() -> ImageFormat {
// A HEIF image file has the first 16 bytes like
// 0000 0018 6674 7970 6865 6963 0000 0000
// so in this case the 5th to 12th bytes shall make a string of "ftypheic"
let heifHeaderStartsAt = 4
let heifBrandStartsAt = 8
// We support "heic", "mif1" or "msf1". Other brands are invalid for us for now.
// The length is 4 + 1 because the brand must be terminated with a null.
// Include the null in the comparison to prevent a bogus brand like "heicfake"
// from being considered valid.
let heifSupportedBrandLength = 5
let totalHeaderLength = heifBrandStartsAt - heifHeaderStartsAt + heifSupportedBrandLength
guard byteLength >= heifBrandStartsAt + heifSupportedBrandLength else {
return .unknown
}
// These are the brands of HEIF formatted files that are renderable by CoreGraphics
let heifBrandHeaderHeic = Data("ftypheic\0".utf8)
let heifBrandHeaderHeif = Data("ftypmif1\0".utf8)
let heifBrandHeaderHeifStream = Data("ftypmsf1\0".utf8)
// Pull the string from the header and compare it with the supported formats
let header = try? readData(byteOffset: heifHeaderStartsAt, byteLength: totalHeaderLength)
if header == heifBrandHeaderHeic {
return .heic
} else if header == heifBrandHeaderHeif || header == heifBrandHeaderHeifStream {
return .heif
} else {
return .unknown
}
}
fileprivate func ows_guessImageFormat() -> ImageFormat {
guard byteLength >= 2 else {
return .unknown
}
switch try? readData(byteOffset: 0, byteLength: 2) {
case Data([0x47, 0x49]):
return .gif
case Data([0x89, 0x50]):
return .png
case Data([0xff, 0xd8]):
return .jpeg
case Data([0x42, 0x4d]):
return .bmp
case Data([0x4d, 0x4d]), // Motorola byte order TIFF
Data([0x49, 0x49]): // Intel byte order TIFF
return .tiff
case Data([0x52, 0x49]):
// First two letters of RIFF tag.
return .webp
default:
return ows_guessHighEfficiencyImageFormat()
}
}
fileprivate static func applyImageOrientation(_ orientation: CGImagePropertyOrientation, to imageSize: CGSize) -> CGSize {
// NOTE: UIImageOrientation and CGImagePropertyOrientation values
// DO NOT match.
switch orientation {
case .up, .upMirrored, .down, .downMirrored:
return imageSize
case .left, .leftMirrored, .right, .rightMirrored:
return CGSize(width: imageSize.height, height: imageSize.width)
}
}
/// Determine whether something is an animated PNG.
///
/// Does this by checking that the `acTL` chunk appears before any `IDAT` chunk.
/// See [the APNG spec][0] for more.
///
/// [0]: https://wiki.mozilla.org/APNG_Specification
///
/// - Returns:
/// `true` if the contents appear to be an APNG.
/// `false` if the contents are a still PNG.
/// `nil` if the contents are invalid.
func isAnimatedPngData() -> NSNumber? {
let actl = "acTL".data(using: .ascii)
let idat = "IDAT".data(using: .ascii)
do {
let chunker = try PngChunker(source: self)
while let chunk = try chunker.next() {
if chunk.type == actl {
return NSNumber(value: true)
} else if chunk.type == idat {
return NSNumber(value: false)
}
}
} catch {
Logger.warn("Error: \(error)")
}
return nil
}
// MARK: - Image Metadata
func imageMetadata(
mimeTypeForValidation declaredMimeType: String?
) -> TSAttachmentMigration.ImageMetadata? {
guard byteLength < TSAttachmentMigration.kMaxFileSizeGeneric else {
return nil
}
let imageFormat = ows_guessImageFormat()
guard imageFormat.isValid(source: self) else {
Logger.warn("Image does not have valid format.")
return nil
}
guard imageFormat.mimeType != nil else {
Logger.warn("Image does not have MIME type.")
return nil
}
let isAnimated: Bool
switch imageFormat {
case .gif:
// This treats all GIFs as animated. We could reflect the actual image content.
isAnimated = true
case .webp:
let webpMetadata = metadataForWebp
guard webpMetadata.isValid else {
Logger.warn("Image does not have valid webpMetadata.")
return nil
}
isAnimated = webpMetadata.frameCount > 1
case .png:
guard let isAnimatedPng = isAnimatedPngData() else {
Logger.warn("Could not determine if png is animated.")
return nil
}
isAnimated = isAnimatedPng.boolValue
default:
isAnimated = false
}
guard imageFormat.isValid(source: self) else {
Logger.warn("Image does not have valid format.")
return nil
}
if isAnimated, byteLength > TSAttachmentMigration.kMaxFileSizeAnimatedImage {
Logger.warn("Oversize image.")
return nil
} else if !isAnimated, byteLength > TSAttachmentMigration.kMaxFileSizeImage {
Logger.warn("Oversize image.")
return nil
}
let metadata = imageMetadata(withIsAnimated: isAnimated, imageFormat: imageFormat)
guard metadata.isValid else {
return nil
}
return metadata
}
fileprivate func imageMetadata(
withIsAnimated isAnimated: Bool,
imageFormat: TSAttachmentMigration.ImageFormat
) -> TSAttachmentMigration.ImageMetadata {
if imageFormat == .webp {
let imageSize = sizeForWebpData
guard Self.ows_isValidImage(dimension: imageSize, depthBytes: 1, isAnimated: isAnimated) else {
Logger.warn("Image does not have valid dimensions: \(imageSize)")
return .invalid()
}
return .init(isValid: true, imageFormat: imageFormat, pixelSize: imageSize, hasAlpha: true, isAnimated: isAnimated)
}
guard let imageSource = try? self.cgImageSource() else {
Logger.warn("Could not build imageSource.")
return .invalid()
}
return Self.imageMetadata(withImageSource: imageSource, imageFormat: imageFormat, isAnimated: isAnimated)
}
fileprivate static func imageMetadata(
withImageSource imageSource: CGImageSource,
imageFormat: TSAttachmentMigration.ImageFormat,
isAnimated: Bool
) -> TSAttachmentMigration.ImageMetadata {
let options = [kCGImageSourceShouldCache as String: false]
guard let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, options as CFDictionary) as? [String: AnyObject] else {
Logger.warn("Missing imageProperties.")
return .invalid()
}
guard let widthNumber = imageProperties[kCGImagePropertyPixelWidth as String] as? NSNumber else {
Logger.warn("widthNumber was unexpectedly nil")
return .invalid()
}
guard let heightNumber = imageProperties[kCGImagePropertyPixelHeight as String] as? NSNumber else {
Logger.warn("heightNumber was unexpectedly nil")
return .invalid()
}
var pixelSize = CGSize(width: widthNumber.doubleValue, height: heightNumber.doubleValue)
if let orientationNumber = imageProperties[kCGImagePropertyOrientation as String] as? NSNumber {
guard let orientation = CGImagePropertyOrientation(rawValue: orientationNumber.uint32Value) else {
Logger.warn("orientation number was invalid")
return .invalid()
}
pixelSize = applyImageOrientation(orientation, to: pixelSize)
}
let hasAlpha = imageProperties[kCGImagePropertyHasAlpha as String] as? NSNumber ?? false
// The number of bits in each color sample of each pixel. The value of this key is a CFNumberRef.
guard let depthNumber = imageProperties[kCGImagePropertyDepth as String] as? NSNumber else {
Logger.warn("depthNumber was unexpectedly nil")
return .invalid()
}
let depthBits = depthNumber.uintValue
// This should usually be 1.
let depthBytes = ceil(Double(depthBits) / 8.0)
// The color model of the image such as "RGB", "CMYK", "Gray", or "Lab". The value of this key is CFStringRef.
guard let colorModel = (imageProperties[kCGImagePropertyColorModel as String] as? NSString) as String? else {
Logger.warn("colorModel was unexpectedly nil")
return .invalid()
}
guard colorModel == kCGImagePropertyColorModelRGB as String || colorModel == kCGImagePropertyColorModelGray as String else {
Logger.warn("Invalid colorModel: \(colorModel)")
return .invalid()
}
guard ows_isValidImage(dimension: pixelSize, depthBytes: depthBytes, isAnimated: isAnimated) else {
Logger.warn("Image does not have valid dimensions: \(pixelSize).")
return .invalid()
}
return .init(isValid: true, imageFormat: imageFormat, pixelSize: pixelSize, hasAlpha: hasAlpha.boolValue, isAnimated: isAnimated)
}
// MARK: - WEBP
fileprivate var sizeForWebpData: CGSize {
let webpMetadata = metadataForWebp
guard webpMetadata.isValid else {
return .zero
}
return .init(width: CGFloat(webpMetadata.canvasWidth), height: CGFloat(webpMetadata.canvasHeight))
}
fileprivate var metadataForWebp: TSAttachmentMigration.WebpMetadata {
guard let data = try? self.readIntoMemory() else {
return WebpMetadata(isValid: false, canvasWidth: 0, canvasHeight: 0, frameCount: 0)
}
return data.withUnsafeBytes {
$0.withMemoryRebound(to: UInt8.self) { buffer in
var webPData = WebPData(bytes: buffer.baseAddress, size: buffer.count)
guard let demuxer = WebPDemux(&webPData) else {
return WebpMetadata(isValid: false, canvasWidth: 0, canvasHeight: 0, frameCount: 0)
}
defer {
WebPDemuxDelete(demuxer)
}
let canvasWidth = WebPDemuxGetI(demuxer, WEBP_FF_CANVAS_WIDTH)
let canvasHeight = WebPDemuxGetI(demuxer, WEBP_FF_CANVAS_HEIGHT)
let frameCount = WebPDemuxGetI(demuxer, WEBP_FF_FRAME_COUNT)
let result = WebpMetadata(isValid: canvasWidth > 0 && canvasHeight > 0 && frameCount > 0,
canvasWidth: canvasWidth,
canvasHeight: canvasHeight,
frameCount: frameCount)
return result
}
}
}
}
}

View File

@ -1,217 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVKit
import Foundation
extension TSAttachmentMigration {
static let kMaxAnimatedImageDimensions: UInt = 12 * 1024
static let kMaxStillImageDimensions: UInt = 12 * 1024
static let kMaxFileSizeAnimatedImage = UInt(25 * 1024 * 1024)
static let kMaxFileSizeImage = UInt(8 * 1024 * 1024)
static let kMaxFileSizeGeneric = UInt(95 * 1000 * 1000)
enum OWSMediaUtils {
// This size is large enough to render full screen.
static func thumbnailDimensionPointsLarge() -> CGFloat {
let screenSizePoints = UIScreen.main.bounds.size
return max(screenSizePoints.width, screenSizePoints.height)
}
static func isValidVideo(asset: AVAsset) -> Bool {
var maxTrackSize = CGSize.zero
for track: AVAssetTrack in asset.tracks(withMediaType: .video) {
let trackSize: CGSize = track.naturalSize
maxTrackSize.width = max(maxTrackSize.width, trackSize.width)
maxTrackSize.height = max(maxTrackSize.height, trackSize.height)
}
if maxTrackSize.width < 1.0 || maxTrackSize.height < 1.0 {
Logger.error("Invalid video size: \(maxTrackSize)")
return false
}
if maxTrackSize.width > 4096 || maxTrackSize.height > 4096 {
Logger.error("Invalid video dimensions: \(maxTrackSize)")
return false
}
return true
}
static func thumbnail(forImage image: UIImage, maxDimensionPixels: CGFloat) throws -> UIImage {
if image.pixelSize.width <= maxDimensionPixels,
image.pixelSize.height <= maxDimensionPixels {
let result = image.withNativeScale
return result
}
guard let thumbnailImage = Self.resize(image: image, maxDimensionPixels: maxDimensionPixels) else {
throw OWSAssertionError("Could not thumbnail image.")
}
guard nil != thumbnailImage.cgImage else {
throw OWSAssertionError("Missing cgImage.")
}
let result = thumbnailImage.withNativeScale
return result
}
static func thumbnail(forVideo asset: AVAsset, maxSizePixels: CGSize) throws -> UIImage {
let generator = AVAssetImageGenerator(asset: asset)
generator.maximumSize = maxSizePixels
generator.appliesPreferredTrackTransform = true
let time: CMTime = CMTimeMake(value: 1, timescale: 60)
let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
let image = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .up)
return image
}
static func resize(image: UIImage, maxDimensionPoints: CGFloat) -> UIImage? {
resize(image: image, originalSize: image.size, maxDimension: maxDimensionPoints, isPixels: false)
}
static func resize(image: UIImage, maxDimensionPixels: CGFloat) -> UIImage? {
resize(image: image, originalSize: image.pixelSize, maxDimension: maxDimensionPixels, isPixels: true)
}
/// Original size and maxDimension should both be in the same units, either points or pixels.
private static func resize(image: UIImage, originalSize: CGSize, maxDimension: CGFloat, isPixels: Bool) -> UIImage? {
if originalSize.width < 1 || originalSize.height < 1 {
Logger.error("Invalid original size: \(originalSize)")
return nil
}
let maxOriginalDimension = max(originalSize.width, originalSize.height)
if maxOriginalDimension < maxDimension {
// Don't bother scaling an image that is already smaller than the max dimension.
return image
}
var unroundedThumbnailSize: CGSize
if originalSize.width > originalSize.height {
unroundedThumbnailSize = CGSize(width: maxDimension, height: maxDimension * originalSize.height / originalSize.width)
} else {
unroundedThumbnailSize = CGSize(width: maxDimension * originalSize.width / originalSize.height, height: maxDimension)
}
var renderRect = CGRect(origin: .zero,
size: CGSize.init(width: round(unroundedThumbnailSize.width),
height: round(unroundedThumbnailSize.height)))
if unroundedThumbnailSize.width < 1 {
// crop instead of resizing.
let newWidth = min(maxDimension, originalSize.width)
let newHeight = originalSize.height * (newWidth / originalSize.width)
renderRect.origin.y = round((maxDimension - newHeight) / 2)
renderRect.size.width = round(newWidth)
renderRect.size.height = round(newHeight)
unroundedThumbnailSize.height = maxDimension
unroundedThumbnailSize.width = newWidth
}
if unroundedThumbnailSize.height < 1 {
// crop instead of resizing.
let newHeight = min(maxDimension, originalSize.height)
let newWidth = originalSize.width * (newHeight / originalSize.height)
renderRect.origin.x = round((maxDimension - newWidth) / 2)
renderRect.size.width = round(newWidth)
renderRect.size.height = round(newHeight)
unroundedThumbnailSize.height = newHeight
unroundedThumbnailSize.width = maxDimension
}
let thumbnailSize = CGSize(width: round(unroundedThumbnailSize.width),
height: round(unroundedThumbnailSize.height))
let format = UIGraphicsImageRendererFormat()
if isPixels {
format.scale = 1
}
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: thumbnailSize, format: format)
return renderer.image { context in
context.cgContext.interpolationQuality = .high
image.draw(in: renderRect)
}
}
}
enum BlurHash {
static func computeBlurHashSync(for image: UIImage) throws -> String {
// Use a small thumbnail size; quality doesn't matter. This is important for perf.
var thumbnail: UIImage
let maxDimensionPixels: CGFloat = 200
if image.pixelSize.width > maxDimensionPixels || image.pixelSize.height > maxDimensionPixels {
thumbnail = try OWSMediaUtils.thumbnail(forImage: image, maxDimensionPixels: maxDimensionPixels)
} else {
thumbnail = image
}
guard let normalized = Self.normalize(image: thumbnail, backgroundColor: .white) else {
throw OWSAssertionError("Could not normalize thumbnail.")
}
// blurHash uses a DCT transform, so these are AC and DC components.
// We use 4x3.
//
// https://github.com/woltapp/blurhash/blob/master/Algorithm.md
guard let blurHash = normalized.blurHash(numberOfComponents: (4, 3)) else {
throw OWSAssertionError("Could not generate blurHash.")
}
guard self.isValidBlurHash(blurHash) else {
throw OWSAssertionError("Generated invalid blurHash.")
}
return blurHash
}
// BlurHashEncode only works with images in a very specific
// pixel format: RGBA8888.
static func normalize(image: UIImage, backgroundColor: UIColor) -> UIImage? {
guard let cgImage = image.cgImage else {
return nil
}
// As long as we're normalizing the image, reduce the size.
// The blurHash algorithm doesn't need more data.
// This also places an upper bound on blurHash perf cost.
let srcSize = image.pixelSize
guard srcSize.width > 0, srcSize.height > 0 else {
return nil
}
let srcMinDimension: CGFloat = min(srcSize.width, srcSize.height)
// Make sure the short dimension is N.
let scale: CGFloat = min(1.0, 16 / srcMinDimension)
let dstWidth: Int = Int(round(srcSize.width * scale))
let dstHeight: Int = Int(round(srcSize.height * scale))
let dstSize = CGSize(width: dstWidth, height: dstHeight)
let dstRect = CGRect(origin: .zero, size: dstSize)
let colorSpace = CGColorSpaceCreateDeviceRGB()
// RGBA8888 pixel format
let bitmapInfo = CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue
guard let context = CGContext(
data: nil,
width: dstWidth,
height: dstHeight,
bitsPerComponent: 8,
bytesPerRow: dstWidth * 4,
space: colorSpace,
bitmapInfo: bitmapInfo
) else {
return nil
}
context.setFillColor(backgroundColor.cgColor)
context.fill(dstRect)
context.draw(cgImage, in: dstRect)
return (context.makeImage().flatMap { UIImage(cgImage: $0) })
}
static func isValidBlurHash(_ blurHash: String?) -> Bool {
guard let blurHash = blurHash else {
return false
}
guard blurHash.count >= 6 && blurHash.count < 100 else {
return false
}
return blurHash.unicodeScalars.allSatisfy {
CharacterSet(
charactersIn: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
).contains($0)
}
}
}
}

View File

@ -1,914 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
extension TSAttachmentMigration {
/// These are the "live" models this migration depends on.
/// We point to the same Swift class/struct model as the live app on the
/// assumption that they will need to always be backwards compatible regardless,
/// so using them here (which requires backwards compatibility) adds no new burden.
///
/// If you are writing a migration to remove these models or update them in a
/// non-backwards compatible way, that migration likely needs to make copies of
/// the pre-migration models so that it knows how to read them before migrating them.
/// This migration should be updated to point at those new copies.
enum LiveModels {
typealias MessageBodyRanges = SignalServiceKit.MessageBodyRanges
typealias SignalServiceAddress = SignalServiceKit.SignalServiceAddress
typealias StyleOnlyMessageBody = SignalServiceKit.StyleOnlyMessageBody
}
struct V1Attachment: Codable, MutablePersistableRecord, FetchableRecord {
static let databaseTableName: String = "model_TSAttachment"
enum AttachmentType: Int, Codable, Equatable {
case `default` = 0
case voiceMessage = 1
case borderless = 2
case gif = 3
var asRenderingFlag: TSAttachmentMigration.V2RenderingFlag {
switch self {
case .default:
return .default
case .voiceMessage:
return .voiceMessage
case .borderless:
return .borderless
case .gif:
return .shouldLoop
}
}
}
static let attachmentPointerSDSRecordType: UInt32 = 3
static let attachmentStreamSDSRecordType: UInt32 = 18
static let attachmentSDSRecordType: UInt32 = 6
var id: Int64?
var recordType: UInt32
var uniqueId: String
var albumMessageId: String?
var attachmentType: V1Attachment.AttachmentType
var blurHash: String?
var byteCount: UInt32
var caption: String?
var contentType: String
var encryptionKey: Data?
var serverId: UInt64
var sourceFilename: String?
var cachedAudioDurationSeconds: Double?
var cachedImageHeight: Double?
var cachedImageWidth: Double?
var creationTimestamp: Double?
var digest: Data?
var isUploaded: Bool?
var isValidImageCached: Bool?
var isValidVideoCached: Bool?
var lazyRestoreFragmentId: String?
var localRelativeFilePath: String?
var mediaSize: Data?
var pointerType: UInt?
var state: UInt32?
var uploadTimestamp: UInt64
var cdnKey: String
var cdnNumber: UInt32
var isAnimatedCached: Bool?
var attachmentSchemaVersion: UInt
var videoDuration: Double?
var clientUuid: String?
func sourceMediaSizePixels() throws -> (height: UInt32, width: UInt32)? {
guard let encoded = mediaSize else {
return nil
}
guard
let decoded = try NSKeyedUnarchiver
.unarchiveTopLevelObjectWithData(encoded) as? CGSize
else {
throw OWSAssertionError("Invalid media size")
}
guard
let height = UInt32(exactly: decoded.height),
let width = UInt32(exactly: decoded.width)
else {
return nil
}
return (height, width)
}
var localFilePath: String? {
guard let localRelativeFilePath else {
return nil
}
let rootPath = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup
)!.path
let attachmentsFolder = rootPath.appendingPathComponent("Attachments")
return attachmentsFolder.appendingPathComponent(localRelativeFilePath)
}
var thumbnailsDirPath: String {
let dirName = "\(uniqueId)-thumbnails"
return OWSFileSystem.cachesDirectoryPath().appendingPathComponent(dirName)
}
var legacyThumbnailPath: String? {
guard let localRelativeFilePath else {
return nil
}
let filename = ((localRelativeFilePath as NSString).lastPathComponent as NSString).deletingPathExtension
let containingDir = (localRelativeFilePath as NSString).deletingLastPathComponent
let newFilename = filename.appending("-signal-ios-thumbnail")
return (containingDir.appendingPathComponent(newFilename) as NSString).appendingPathExtension("jpg")
}
var uniqueIdAttachmentFolder: String {
let rootPath = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup
)!.path
let attachmentsFolder = rootPath.appendingPathComponent("Attachments")
return attachmentsFolder.appendingPathComponent(self.uniqueId)
}
func deleteFiles() throws {
// Ignore failure cuz its a cache directory anyway.
_ = OWSFileSystem.deleteFileIfExists(thumbnailsDirPath)
if let legacyThumbnailPath {
guard OWSFileSystem.deleteFileIfExists(legacyThumbnailPath) else {
throw OWSAssertionError("Failed to delete file")
}
}
if let localFilePath {
guard OWSFileSystem.deleteFileIfExists(localFilePath) else {
throw OWSAssertionError("Failed to delete file")
}
}
guard OWSFileSystem.deleteFileIfExists(uniqueIdAttachmentFolder) else {
throw OWSAssertionError("Failed to delete folder")
}
}
func deleteMediaGalleryRecord(tx: DBWriteTransaction) throws {
try tx.database.execute(
sql: "DELETE FROM media_gallery_items WHERE attachmentId = ?",
arguments: [self.id]
)
}
}
struct V1AttachmentReservedFileIds: Codable, MutablePersistableRecord, FetchableRecord {
static let databaseTableName: String = "TSAttachmentMigration"
var tsAttachmentUniqueId: String
var interactionRowId: Int64?
var storyMessageRowId: Int64?
var reservedV2AttachmentPrimaryFileId: UUID
var reservedV2AttachmentAudioWaveformFileId: UUID
var reservedV2AttachmentVideoStillFrameFileId: UUID
static var databaseUUIDEncodingStrategy: DatabaseUUIDEncodingStrategy = .deferredToUUID
init(
tsAttachmentUniqueId: String,
interactionRowId: Int64?,
storyMessageRowId: Int64?,
reservedV2AttachmentPrimaryFileId: UUID,
reservedV2AttachmentAudioWaveformFileId: UUID,
reservedV2AttachmentVideoStillFrameFileId: UUID
) {
self.tsAttachmentUniqueId = tsAttachmentUniqueId
self.interactionRowId = interactionRowId
self.storyMessageRowId = storyMessageRowId
self.reservedV2AttachmentPrimaryFileId = reservedV2AttachmentPrimaryFileId
self.reservedV2AttachmentAudioWaveformFileId = reservedV2AttachmentAudioWaveformFileId
self.reservedV2AttachmentVideoStillFrameFileId = reservedV2AttachmentVideoStillFrameFileId
}
func cleanUpFiles() {
for uuid in [
self.reservedV2AttachmentPrimaryFileId,
self.reservedV2AttachmentAudioWaveformFileId,
self.reservedV2AttachmentVideoStillFrameFileId
] {
let relPath = TSAttachmentMigration.V2Attachment.relativeFilePath(reservedUUID: uuid)
let fileUrl = TSAttachmentMigration.V2Attachment.absoluteAttachmentFileURL(
relativeFilePath: relPath
)
do {
try OWSFileSystem.deleteFileIfExists(url: fileUrl)
} catch {
owsFail("Unable to clean up reserved files")
}
}
}
}
struct V2Attachment: Codable, MutablePersistableRecord, FetchableRecord {
static let databaseTableName: String = "Attachment"
enum ContentType: Int {
case invalid = 0
case file = 1
case image = 2
case video = 3
case animatedImage = 4
case audio = 5
}
var id: Int64?
var blurHash: String?
var sha256ContentHash: Data?
var encryptedByteCount: UInt32?
var unencryptedByteCount: UInt32?
var mimeType: String
var encryptionKey: Data
var digestSHA256Ciphertext: Data?
var contentType: UInt32?
var transitCdnNumber: UInt32?
var transitCdnKey: String?
var transitUploadTimestamp: UInt64?
var transitEncryptionKey: Data?
var transitUnencryptedByteCount: UInt32?
var transitDigestSHA256Ciphertext: Data?
var lastTransitDownloadAttemptTimestamp: UInt64?
var mediaName: String?
var mediaTierCdnNumber: UInt32?
var mediaTierUnencryptedByteCount: UInt32?
var mediaTierUploadEra: String?
var lastMediaTierDownloadAttemptTimestamp: UInt64?
var thumbnailCdnNumber: UInt32?
var thumbnailUploadEra: String?
var lastThumbnailDownloadAttemptTimestamp: UInt64?
var localRelativeFilePath: String?
var localRelativeFilePathThumbnail: String?
var cachedAudioDurationSeconds: Double?
var cachedMediaHeightPixels: UInt32?
var cachedMediaWidthPixels: UInt32?
var cachedVideoDurationSeconds: Double?
var audioWaveformRelativeFilePath: String?
var videoStillFrameRelativeFilePath: String?
var originalAttachmentIdForQuotedReply: Int64?
init(
id: Int64? = nil,
blurHash: String?,
sha256ContentHash: Data?,
encryptedByteCount: UInt32?,
unencryptedByteCount: UInt32?,
mimeType: String,
encryptionKey: Data,
digestSHA256Ciphertext: Data?,
contentType: UInt32?,
transitCdnNumber: UInt32?,
transitCdnKey: String?,
transitUploadTimestamp: UInt64?,
transitEncryptionKey: Data?,
transitUnencryptedByteCount: UInt32?,
transitDigestSHA256Ciphertext: Data?,
lastTransitDownloadAttemptTimestamp: UInt64?,
localRelativeFilePath: String?,
cachedAudioDurationSeconds: Double?,
cachedMediaHeightPixels: UInt32?,
cachedMediaWidthPixels: UInt32?,
cachedVideoDurationSeconds: Double?,
audioWaveformRelativeFilePath: String?,
videoStillFrameRelativeFilePath: String?
) {
self.id = id
self.blurHash = blurHash
self.sha256ContentHash = sha256ContentHash
self.encryptedByteCount = encryptedByteCount
self.unencryptedByteCount = unencryptedByteCount
self.mimeType = mimeType
self.encryptionKey = encryptionKey
self.digestSHA256Ciphertext = digestSHA256Ciphertext
self.contentType = contentType
// We only set transit tier fields if they are all set.
if
let transitCdnNumber,
transitCdnNumber != 0,
let transitCdnKey = transitCdnKey?.nilIfEmpty,
let transitEncryptionKey,
!transitEncryptionKey.isEmpty,
let transitUnencryptedByteCount,
let transitDigestSHA256Ciphertext,
!transitDigestSHA256Ciphertext.isEmpty
{
self.transitCdnNumber = transitCdnNumber
self.transitCdnKey = transitCdnKey
self.transitUploadTimestamp = transitUploadTimestamp ?? Date().ows_millisecondsSince1970
self.transitEncryptionKey = transitEncryptionKey
self.transitUnencryptedByteCount = transitUnencryptedByteCount
self.transitDigestSHA256Ciphertext = transitDigestSHA256Ciphertext
} else {
self.transitCdnNumber = nil
self.transitCdnKey = nil
self.transitUploadTimestamp = nil
self.transitEncryptionKey = nil
self.transitUnencryptedByteCount = nil
self.transitDigestSHA256Ciphertext = nil
}
self.lastTransitDownloadAttemptTimestamp = lastTransitDownloadAttemptTimestamp
self.mediaName = digestSHA256Ciphertext.map {
TSAttachmentMigration.V2Attachment.mediaName(
digestSHA256Ciphertext: $0
)
}
// Media tier and thumbnail upload info was unsupported in TSAttachment
// and therefore will always be nil in this migration.
self.mediaTierCdnNumber = nil
self.mediaTierUnencryptedByteCount = nil
self.mediaTierUploadEra = nil
self.lastMediaTierDownloadAttemptTimestamp = nil
self.thumbnailCdnNumber = nil
self.thumbnailUploadEra = nil
self.lastThumbnailDownloadAttemptTimestamp = nil
self.localRelativeFilePath = localRelativeFilePath
self.localRelativeFilePathThumbnail = nil
self.cachedAudioDurationSeconds = cachedAudioDurationSeconds
self.cachedMediaHeightPixels = cachedMediaHeightPixels
self.cachedMediaWidthPixels = cachedMediaWidthPixels
self.cachedVideoDurationSeconds = cachedVideoDurationSeconds
self.audioWaveformRelativeFilePath = audioWaveformRelativeFilePath
self.videoStillFrameRelativeFilePath = videoStillFrameRelativeFilePath
// In the migration we never reference a quote original since the original
// is likely unmigrated when we migrate the quoted reply.
self.originalAttachmentIdForQuotedReply = nil
}
mutating func didInsert(with rowID: Int64, for column: String?) {
self.id = rowID
}
static func relativeFilePath(reservedUUID: UUID) -> String {
let id = reservedUUID.uuidString
return "\(id.prefix(2))/\(id)"
}
static func absoluteAttachmentFileURL(relativeFilePath: String) -> URL {
let rootUrl = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup
)!
let directory = rootUrl.appendingPathComponent("attachment_files")
return directory.appendingPathComponent(relativeFilePath)
}
static func mediaName(digestSHA256Ciphertext: Data) -> String {
return digestSHA256Ciphertext.hexadecimalString
}
}
enum V2RenderingFlag: Int {
case `default` = 0
case voiceMessage = 1
case borderless = 2
case shouldLoop = 3
}
enum V2MessageAttachmentOwnerType: Int {
case bodyAttachment = 0
case oversizeText = 1
case linkPreview = 2
case quotedReplyAttachment = 3
case sticker = 4
case contactAvatar = 5
}
struct MessageAttachmentReference: Codable, PersistableRecord, FetchableRecord {
static let databaseTableName: String = "MessageAttachmentReference"
var ownerType: UInt32
var ownerRowId: Int64
var attachmentRowId: Int64
var receivedAtTimestamp: UInt64
var contentType: UInt32?
var renderingFlag: UInt32
var idInMessage: String?
var orderInMessage: UInt32?
var threadRowId: Int64
var caption: String?
var sourceFilename: String?
var sourceUnencryptedByteCount: UInt32?
var sourceMediaHeightPixels: UInt32?
var sourceMediaWidthPixels: UInt32?
var stickerPackId: Data?
var stickerId: UInt32?
var isViewOnce: Bool
var ownerIsPastEditRevision: Bool
}
struct StoryMessageAttachmentReference: Codable, PersistableRecord, FetchableRecord {
static let databaseTableName: String = "StoryMessageAttachmentReference"
var ownerType: UInt32
var ownerRowId: Int64
var attachmentRowId: Int64
var shouldLoop: Bool
var caption: String?
var captionBodyRanges: Data?
var sourceFilename: String?
var sourceUnencryptedByteCount: UInt32?
var sourceMediaHeightPixels: UInt32?
var sourceMediaWidthPixels: UInt32?
}
struct ThreadAttachmentReference: Codable, PersistableRecord, FetchableRecord {
static let databaseTableName: String = "ThreadAttachmentReference"
var ownerRowId: Int64?
var attachmentRowId: Int64
var creationTimestamp: UInt64
}
// MARK: - MTLModels
private static let nsCodingMappings: [String: AnyClass] = [
"SignalServiceKit.OWSLinkPreview": TSAttachmentMigration.OWSLinkPreview.self,
"StickerInfo": TSAttachmentMigration.StickerInfo.self,
"SignalServiceKit.MessageSticker": TSAttachmentMigration.MessageSticker.self,
"OWSContact": TSAttachmentMigration.OWSContact.self,
"OWSContactAddress": TSAttachmentMigration.OWSContactAddress.self,
"OWSContactEmail": TSAttachmentMigration.OWSContactEmail.self,
"OWSContactName": TSAttachmentMigration.OWSContactName.self,
"OWSContactPhoneNumber": TSAttachmentMigration.OWSContactPhoneNumber.self,
"OWSAttachmentInfo": TSAttachmentMigration.OWSAttachmentInfo.self,
"TSQuotedMessage": TSAttachmentMigration.TSQuotedMessage.self,
"SignalServiceKit.MessageBodyRanges": LiveModels.MessageBodyRanges.self,
"SignalServiceKit.SignalServiceAddress": LiveModels.SignalServiceAddress.self,
]
static func prepareNSCodingMappings(archiver: NSKeyedArchiver) {
Self.nsCodingMappings.forEach { originalClassName, migrationClass in
archiver.setClassName(originalClassName, for: migrationClass)
}
}
static func prepareNSCodingMappings(unarchiver: NSKeyedUnarchiver) {
Self.nsCodingMappings.forEach { originalClassName, migrationClass in
unarchiver.setClass(migrationClass, forClassName: originalClassName)
}
}
static func cleanUpNSCodingMappings(archiver: NSKeyedArchiver) {
Self.nsCodingMappings.forEach { originalClassName, migrationClass in
archiver.setClassName(nil, for: migrationClass)
}
}
static func cleanUpNSCodingMappings(unarchiver: NSKeyedUnarchiver) {
Self.nsCodingMappings.forEach { originalClassName, migrationClass in
unarchiver.setClass(nil, forClassName: originalClassName)
}
}
@objcMembers
class OWSLinkPreview: MTLModel, Codable {
var urlString: String?
var title: String?
var imageAttachmentId: String?
var usesV2AttachmentReferenceValue: NSNumber?
var previewDescription: String?
var date: Date?
override init() { super.init() }
required init!(coder: NSCoder) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
enum CodingKeys: String, CodingKey {
case urlString
case title
case usesV2AttachmentReferenceValue
case imageAttachmentId
case previewDescription
case date
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
urlString = try container.decodeIfPresent(String.self, forKey: .urlString)
title = try container.decodeIfPresent(String.self, forKey: .title)
let usesV2AttachmentReferenceValue = try container.decodeIfPresent(Int.self, forKey: .usesV2AttachmentReferenceValue)
self.usesV2AttachmentReferenceValue = usesV2AttachmentReferenceValue.map(NSNumber.init(integerLiteral:))
imageAttachmentId = try container.decodeIfPresent(String.self, forKey: .imageAttachmentId)
previewDescription = try container.decodeIfPresent(String.self, forKey: .previewDescription)
date = try container.decodeIfPresent(Date.self, forKey: .date)
super.init()
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(urlString, forKey: .urlString)
try container.encodeIfPresent(title, forKey: .title)
try container.encodeIfPresent(usesV2AttachmentReferenceValue?.intValue, forKey: .usesV2AttachmentReferenceValue)
try container.encodeIfPresent(imageAttachmentId, forKey: .imageAttachmentId)
try container.encodeIfPresent(previewDescription, forKey: .previewDescription)
try container.encodeIfPresent(date, forKey: .date)
}
}
@objcMembers
class StickerInfo: MTLModel {
var packId: Data = Randomness.generateRandomBytes(16)
var packKey: Data = Randomness.generateRandomBytes(32)
var stickerId: UInt32 = 0
override init() { super.init() }
required init!(coder: NSCoder!) { super.init(coder: coder) }
required init(dictionary: [String: Any]!) throws {
try super.init(dictionary: dictionary)
}
}
@objcMembers
class MessageSticker: MTLModel {
var info = TSAttachmentMigration.StickerInfo()
var attachmentId: String?
var emoji: String?
override init() { super.init() }
required init!(coder: NSCoder) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objcMembers
public class OWSContactName: MTLModel {
var givenName: String?
var familyName: String?
var namePrefix: String?
var nameSuffix: String?
var middleName: String?
var nickname: String?
var organizationName: String?
override init() { super.init() }
required init!(coder: NSCoder!) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objcMembers
class OWSContactPhoneNumber: MTLModel {
@objc
enum `Type`: Int {
case home = 1
case mobile
case work
case custom
}
var phoneType: `Type` = .home
var label: String?
var phoneNumber: String = ""
override init() { super.init() }
required init!(coder: NSCoder!) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objcMembers
class OWSContactEmail: MTLModel {
@objc
enum `Type`: Int {
case home = 1
case mobile
case work
case custom
}
var emailType: `Type` = .home
var label: String?
var email: String = ""
override init() { super.init() }
required init!(coder: NSCoder!) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objcMembers
class OWSContactAddress: MTLModel {
@objc
enum `Type`: Int {
case home = 1
case work
case custom
}
var addressType: `Type` = .home
var label: String?
var street: String?
var pobox: String?
var neighborhood: String?
var city: String?
var region: String?
var postcode: String?
var country: String?
override init() { super.init() }
required init!(coder: NSCoder!) { super.init(coder: coder) }
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objcMembers
class OWSContact: MTLModel {
var name: TSAttachmentMigration.OWSContactName
var phoneNumbers: [TSAttachmentMigration.OWSContactPhoneNumber] = []
var emails: [TSAttachmentMigration.OWSContactEmail] = []
var addresses: [TSAttachmentMigration.OWSContactAddress] = []
var avatarAttachmentId: String?
override init() {
self.name = TSAttachmentMigration.OWSContactName()
super.init()
}
required init!(coder: NSCoder!) {
self.name = TSAttachmentMigration.OWSContactName()
super.init(coder: coder)
}
required init(dictionary dictionaryValue: [String: Any]!) throws {
self.name = TSAttachmentMigration.OWSContactName()
try super.init(dictionary: dictionaryValue)
}
}
@objc
enum OWSAttachmentInfoReference: Int, Codable {
case unset = 0
case originalForSend = 1
case original = 2
case thumbnail = 3
case untrustedPointer = 4
case v2 = 5
}
@objcMembers
class OWSAttachmentInfo: MTLModel, NSSecureCoding {
var schemaVersion: UInt = 1
var attachmentType: TSAttachmentMigration.OWSAttachmentInfoReference = .unset
var rawAttachmentId: String = ""
var contentType: String?
var sourceFilename: String?
static var supportsSecureCoding: Bool = false
init(
schemaVersion: UInt = 1,
attachmentType: TSAttachmentMigration.OWSAttachmentInfoReference,
rawAttachmentId: String,
contentType: String?,
sourceFilename: String?
) {
self.schemaVersion = schemaVersion
self.attachmentType = attachmentType
self.rawAttachmentId = rawAttachmentId
self.contentType = contentType
self.sourceFilename = sourceFilename
super.init()
}
override init() { super.init() }
required init!(coder: NSCoder!) {
super.init(coder: coder)
if schemaVersion == 0 {
let oldStreamId = coder.decodeObject(of: NSString.self, forKey: "thumbnailAttachmentStreamId")
let oldPointerId = coder.decodeObject(of: NSString.self, forKey: "thumbnailAttachmentPointerId")
let oldSourceAttachmentId = coder.decodeObject(of: NSString.self, forKey: "attachmentId")
// Before, we maintained each of these IDs in parallel, though in practice only one in use at a time.
// Migration codifies this behavior.
if let oldStreamId, oldPointerId == oldStreamId {
attachmentType = .thumbnail
rawAttachmentId = oldStreamId as String
} else if let oldPointerId {
attachmentType = .untrustedPointer
rawAttachmentId = oldPointerId as String
} else if let oldStreamId {
attachmentType = .thumbnail
rawAttachmentId = oldStreamId as String
} else if let oldSourceAttachmentId {
attachmentType = .originalForSend
rawAttachmentId = oldSourceAttachmentId as String
} else {
attachmentType = .unset
rawAttachmentId = ""
}
}
self.schemaVersion = 1
}
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
@objc
enum TSQuotedMessageContentSource: Int, Codable {
case unknown = 0
case local = 1
case remote = 2
case story = 3
}
@objcMembers
class TSQuotedMessage: MTLModel {
var timestamp: UInt64 = 0
var authorAddress: LiveModels.SignalServiceAddress?
var bodySource: TSAttachmentMigration.TSQuotedMessageContentSource = .unknown
var body: String?
var bodyRanges: LiveModels.MessageBodyRanges?
var quotedAttachment: TSAttachmentMigration.OWSAttachmentInfo?
var isGiftBadge: Bool = false
override init() { super.init() }
required init!(coder: NSCoder!) {
super.init(coder: coder)
if authorAddress == nil, let phoneNumber = coder.decodeObject(of: NSString.self, forKey: "authorId") {
authorAddress = LiveModels.SignalServiceAddress.legacyAddress(aciString: nil, phoneNumber: phoneNumber as String)
}
if
quotedAttachment == nil,
let array = coder.decodeObject(of: NSArray.self, forKey: "quotedAttachments"),
let first = array.firstObject as? TSAttachmentMigration.OWSAttachmentInfo
{
quotedAttachment = first
} else if
quotedAttachment == nil,
let quotedAttachment = coder.decodeObject(of: TSAttachmentMigration.OWSAttachmentInfo.self, forKey: "quotedAttachments")
{
self.quotedAttachment = quotedAttachment
}
}
required init(dictionary dictionaryValue: [String: Any]!) throws {
try super.init(dictionary: dictionaryValue)
}
}
// MARK: - Styles
struct NSRangedValue<T> {
let range: NSRange
let value: T
}
struct Style: OptionSet, Codable {
let rawValue: Int
init(rawValue: Int) {
self.rawValue = rawValue
}
}
enum SingleStyle: Int, Codable {
case bold = 1
case italic = 2
case spoiler = 4
case strikethrough = 8
case monospace = 16
}
struct MergedSingleStyle: Equatable, Codable {
let style: TSAttachmentMigration.SingleStyle
let mergedRange: NSRange
let id: Int
}
struct CollapsedStyle: Equatable, Codable {
let style: TSAttachmentMigration.Style
let originals: [TSAttachmentMigration.SingleStyle: TSAttachmentMigration.MergedSingleStyle]
}
// MARK: - Stories
enum SerializedStoryMessageAttachment: Codable {
case file(attachmentId: String)
case text(attachment: TSAttachmentMigration.TextAttachment)
case fileV2(TSAttachmentMigration.StoryMessageFileAttachment)
case foreignReferenceAttachment
}
struct StoryMessageFileAttachment: Codable {
let attachmentId: String
let captionStyles: [TSAttachmentMigration.NSRangedValue<TSAttachmentMigration.CollapsedStyle>]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.attachmentId = try container.decode(String.self, forKey: .attachmentId)
do {
// A year prior to this migration being written, captionStyles contained raw Styles
// instead of collapsed styles. Stories expire in 24 hours. Byt the time of this
// migration any story with the non-collapsed style is expired; technically though
// this migration runs before StoryManager deletes expired stories. So we need to not
// fail, but its ok to drop the caption styles since its about to be deleted anyway.
self.captionStyles = try container.decode([TSAttachmentMigration.NSRangedValue<TSAttachmentMigration.CollapsedStyle>].self, forKey: .captionStyles)
} catch {
self.captionStyles = []
}
}
}
struct TextAttachment: Codable, Equatable {
enum TextStyle: Int, Codable, Equatable {
case regular = 0
case bold = 1
case serif = 2
case script = 3
case condensed = 4
}
enum RawBackground: Codable, Equatable {
case color(hex: UInt32)
case gradient(raw: Self.RawGradient)
struct RawGradient: Codable, Equatable {
let colors: [UInt32]
let positions: [Float]
let angle: UInt32
}
}
let body: LiveModels.StyleOnlyMessageBody?
let textStyle: Self.TextStyle
var preview: TSAttachmentMigration.OWSLinkPreview?
let textForegroundColorHex: UInt32?
let textBackgroundColorHex: UInt32?
let rawBackground: Self.RawBackground
enum CodingKeys: String, CodingKey {
case body = "text"
case textStyle
case textForegroundColorHex
case textBackgroundColorHex
case rawBackground
case preview
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
// Backwards compability; this used to contain just a raw string,
// which we now interpret as a style-less string.
if let rawText = try container.decodeIfPresent(String.self, forKey: .body) {
self.body = LiveModels.StyleOnlyMessageBody(plaintext: rawText)
} else {
self.body = nil
}
} catch {
self.body = try container.decodeIfPresent(LiveModels.StyleOnlyMessageBody.self, forKey: .body)
}
self.textStyle = try container.decode(Self.TextStyle.self, forKey: .textStyle)
self.textForegroundColorHex = try container.decodeIfPresent(UInt32.self, forKey: .textForegroundColorHex)
self.textBackgroundColorHex = try container.decodeIfPresent(UInt32.self, forKey: .textBackgroundColorHex)
self.rawBackground = try container.decode(Self.RawBackground.self, forKey: .rawBackground)
self.preview = try container.decodeIfPresent(TSAttachmentMigration.OWSLinkPreview.self, forKey: .preview)
}
}
}
extension TSAttachmentMigration.NSRangedValue: Codable where T: Codable {}

View File

@ -1,316 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
extension TSAttachmentMigration {
/// Migration for StoryMessage-owned TSAttachments.
/// After migrating they are v2 attachments in the StoryMessageAttachmentReference table.
///
/// The migration works in two passes which must happen in separate write transactions but
/// must be run back to back. (Why? Filesystem changes are not part of the db transaction,
/// so we need to first "reserve" the final file location in the db and then write the file. If the latter
/// step fails we will just rewrite to the same file location next time we retry.)
///
/// Phase 1: Read the StoryMessage table, for each story message create
/// a TSAttachmentMigration row with the "reserved" (random) final file location.
///
/// Phase 2: for each reserved file location, do attachment validation and create the v2 attachments.
///
/// This migration is run up front as a blocking GRDB migration, because most people have very
/// few story messages so its not worth the complexity to run this incrementally.
enum StoryMessageMigration {
/// Phase 1
static func prepareStoryMessageMigration(tx: DBWriteTransaction) throws {
let storyMessageCursor = try Row.fetchCursor(
tx.database,
sql: "SELECT id, attachment FROM model_StoryMessage"
)
// The `attachment` column is a SerializedStoryMessageAttachment encoded as a JSON string.
let decoder = JSONDecoder()
while let storyMessageRow = try storyMessageCursor.next() {
guard
let storyMessageRowId = storyMessageRow["id"] as? Int64,
let storyAttachmentString = storyMessageRow["attachment"] as? String
else {
throw OWSAssertionError("Unexpected row format")
}
let storyMessageAttachmentData = Data(storyAttachmentString.utf8)
let storyAttachment = try decoder.decode(
TSAttachmentMigration.SerializedStoryMessageAttachment.self,
from: storyMessageAttachmentData
)
guard let tsAttachmentUniqueId = storyAttachment.tsAttachmentUniqueId else { continue }
var reservedFileIds = TSAttachmentMigration.V1AttachmentReservedFileIds(
tsAttachmentUniqueId: tsAttachmentUniqueId,
interactionRowId: nil,
storyMessageRowId: storyMessageRowId,
reservedV2AttachmentPrimaryFileId: UUID(),
reservedV2AttachmentAudioWaveformFileId: UUID(),
reservedV2AttachmentVideoStillFrameFileId: UUID()
)
try reservedFileIds.insert(tx.database)
}
}
/// Phase 2
static func completeStoryMessageMigration(tx: DBWriteTransaction) throws {
let decoder = JSONDecoder()
let encoder = JSONEncoder()
let reservedFileIdsCursor = try TSAttachmentMigration.V1AttachmentReservedFileIds
.filter(Column("storyMessageRowId") != nil)
.fetchCursor(tx.database)
var deletedAttachments = [TSAttachmentMigration.V1Attachment]()
while let reservedFileIds = try reservedFileIdsCursor.next() {
guard let storyMessageRowId = reservedFileIds.storyMessageRowId else {
continue
}
let storyAttachmentString = try String.fetchOne(
tx.database,
sql: "SELECT attachment FROM model_StoryMessage WHERE id = ?;",
arguments: [storyMessageRowId]
)
guard let storyAttachmentString else {
reservedFileIds.cleanUpFiles()
continue
}
let storyMessageAttachmentData = Data(storyAttachmentString.utf8)
// The `attachment` column is a SerializedStoryMessageAttachment encoded as a JSON string.
let storyAttachment = try decoder.decode(
TSAttachmentMigration.SerializedStoryMessageAttachment.self,
from: storyMessageAttachmentData
)
guard let tsAttachmentUniqueId = storyAttachment.tsAttachmentUniqueId else {
reservedFileIds.cleanUpFiles()
continue
}
try Self.migrateStoryMessageAttachment(
reservedFileIds: reservedFileIds,
storyAttachment: storyAttachment,
storyMessageRowId: storyMessageRowId,
tsAttachmentUniqueId: tsAttachmentUniqueId,
tx: tx
)
// Update the story message.
let updatedStoryAttachment: TSAttachmentMigration.SerializedStoryMessageAttachment = {
switch storyAttachment {
case .file, .fileV2, .foreignReferenceAttachment:
return .foreignReferenceAttachment
case .text(var textAttachment):
let preview = textAttachment.preview
preview?.imageAttachmentId = nil
preview?.usesV2AttachmentReferenceValue = NSNumber(value: true)
textAttachment.preview = preview
return .text(attachment: textAttachment)
}
}()
let updatedStoryAttachmentRaw = try encoder.encode(updatedStoryAttachment)
try tx.database.execute(
sql: """
UPDATE model_StoryMessage
SET attachment = ?
WHERE id = ?;
""",
arguments: [updatedStoryAttachmentRaw, storyMessageRowId]
)
// Delete the attachment.
let deletedAttachment = try TSAttachmentMigration.V1Attachment.fetchOne(
tx.database,
sql: "DELETE FROM model_TSAttachment WHERE uniqueId = ? RETURNING *",
arguments: [tsAttachmentUniqueId]
)
deletedAttachment.map { deletedAttachments.append($0) }
}
// Delete our reserved rows.
try TSAttachmentMigration.V1AttachmentReservedFileIds
.filter(Column("storyMessageRowId") != nil)
.deleteAll(tx.database)
tx.addSyncCompletion {
Task {
// Delete the files asynchronously after committing the tx. We can't do it
// inside the tx because if the tx is rolled back we DON'T want the files gone.
// This does mean we might fail to delete the files; we will delete the whole
// TSAttachment folder after migrating everything anyway so its not a huge deal.
deletedAttachments.forEach { try? $0.deleteFiles() }
}
}
}
/// Migrates a single story message's attachment.
private static func migrateStoryMessageAttachment(
reservedFileIds: TSAttachmentMigration.V1AttachmentReservedFileIds,
storyAttachment: TSAttachmentMigration.SerializedStoryMessageAttachment,
storyMessageRowId: Int64,
tsAttachmentUniqueId: String,
tx: DBWriteTransaction
) throws {
let oldAttachment = try TSAttachmentMigration.V1Attachment
.filter(Column("uniqueId") == tsAttachmentUniqueId)
.fetchOne(tx.database)
guard let oldAttachment else {
reservedFileIds.cleanUpFiles()
return
}
let renderingFlag = oldAttachment.attachmentType.asRenderingFlag
let pendingAttachment: TSAttachmentMigration.PendingV2AttachmentFile?
if let oldFilePath = oldAttachment.localFilePath {
do {
pendingAttachment = try TSAttachmentMigration.V2AttachmentContentValidator.validateContents(
unencryptedFileUrl: URL(fileURLWithPath: oldFilePath),
reservedFileIds: .init(
primaryFile: reservedFileIds.reservedV2AttachmentPrimaryFileId,
audioWaveform: reservedFileIds.reservedV2AttachmentAudioWaveformFileId,
videoStillFrame: reservedFileIds.reservedV2AttachmentVideoStillFrameFileId
),
attachmentKey: oldAttachment.encryptionKey.map(AttachmentKey.init(combinedKey:)),
mimeType: oldAttachment.contentType,
renderingFlag: renderingFlag,
sourceFilename: oldAttachment.sourceFilename
)
} catch {
Logger.error("Failed to read story attachment file \((error as NSError).domain) \((error as NSError).code)")
// Clean up files just in case.
reservedFileIds.cleanUpFiles()
pendingAttachment = nil
}
} else {
// A pointer; no validation needed.
pendingAttachment = nil
// Clean up files just in case.
reservedFileIds.cleanUpFiles()
}
let v2AttachmentId: Int64
if
let pendingAttachment,
let existingV2Attachment = try TSAttachmentMigration.V2Attachment
.filter(Column("sha256ContentHash") == pendingAttachment.sha256ContentHash)
.fetchOne(tx.database)
{
// If we already have a v2 attachment with the same plaintext hash,
// create new references to it and drop the pending attachment.
v2AttachmentId = existingV2Attachment.id!
// Delete the reserved files being used by the pending attachment.
reservedFileIds.cleanUpFiles()
} else {
var v2Attachment: TSAttachmentMigration.V2Attachment
if let pendingAttachment {
v2Attachment = TSAttachmentMigration.V2Attachment(
blurHash: pendingAttachment.blurHash,
sha256ContentHash: pendingAttachment.sha256ContentHash,
encryptedByteCount: pendingAttachment.encryptedByteCount,
unencryptedByteCount: pendingAttachment.unencryptedByteCount,
mimeType: pendingAttachment.mimeType,
encryptionKey: pendingAttachment.encryptionKey,
digestSHA256Ciphertext: pendingAttachment.digestSHA256Ciphertext,
contentType: UInt32(pendingAttachment.validatedContentType.rawValue),
transitCdnNumber: oldAttachment.cdnNumber,
transitCdnKey: oldAttachment.cdnKey,
transitUploadTimestamp: oldAttachment.uploadTimestamp,
transitEncryptionKey: oldAttachment.encryptionKey,
transitUnencryptedByteCount: pendingAttachment.unencryptedByteCount,
transitDigestSHA256Ciphertext: oldAttachment.digest,
lastTransitDownloadAttemptTimestamp: nil,
localRelativeFilePath: pendingAttachment.localRelativeFilePath,
cachedAudioDurationSeconds: pendingAttachment.audioDurationSeconds,
cachedMediaHeightPixels: pendingAttachment.mediaSizePixels.map { UInt32($0.height) },
cachedMediaWidthPixels: pendingAttachment.mediaSizePixels.map { UInt32($0.width) },
cachedVideoDurationSeconds: pendingAttachment.videoDurationSeconds,
audioWaveformRelativeFilePath: pendingAttachment.audioWaveformRelativeFilePath,
videoStillFrameRelativeFilePath: pendingAttachment.videoStillFrameRelativeFilePath
)
} else {
v2Attachment = TSAttachmentMigration.V2Attachment(
blurHash: oldAttachment.blurHash,
sha256ContentHash: nil,
encryptedByteCount: nil,
unencryptedByteCount: nil,
mimeType: oldAttachment.contentType,
encryptionKey: oldAttachment.encryptionKey ?? AttachmentKey.generate().combinedKey,
digestSHA256Ciphertext: nil,
contentType: nil,
transitCdnNumber: oldAttachment.cdnNumber,
transitCdnKey: oldAttachment.cdnKey,
transitUploadTimestamp: oldAttachment.uploadTimestamp,
transitEncryptionKey: oldAttachment.encryptionKey,
transitUnencryptedByteCount: oldAttachment.byteCount,
transitDigestSHA256Ciphertext: oldAttachment.digest,
lastTransitDownloadAttemptTimestamp: nil,
localRelativeFilePath: pendingAttachment?.localRelativeFilePath,
cachedAudioDurationSeconds: nil,
cachedMediaHeightPixels: nil,
cachedMediaWidthPixels: nil,
cachedVideoDurationSeconds: nil,
audioWaveformRelativeFilePath: nil,
videoStillFrameRelativeFilePath: nil
)
}
try v2Attachment.insert(tx.database)
v2AttachmentId = v2Attachment.id!
}
let mediaStoryOwnerType: UInt32 = 0
let textStoryOwnerType: UInt32 = 1
let ownerType: UInt32
let captionBodyRanges: [TSAttachmentMigration.NSRangedValue<TSAttachmentMigration.CollapsedStyle>]?
switch storyAttachment {
case .file:
ownerType = mediaStoryOwnerType
captionBodyRanges = nil
case .fileV2(let fileAttachment):
ownerType = mediaStoryOwnerType
captionBodyRanges = fileAttachment.captionStyles
case .text(_):
ownerType = textStoryOwnerType
captionBodyRanges = nil
case .foreignReferenceAttachment:
return
}
let (sourceMediaHeightPixels, sourceMediaWidthPixels) = try oldAttachment.sourceMediaSizePixels() ?? (nil, nil)
let reference = TSAttachmentMigration.StoryMessageAttachmentReference(
ownerType: ownerType,
ownerRowId: storyMessageRowId,
attachmentRowId: v2AttachmentId,
shouldLoop: renderingFlag == .shouldLoop,
caption: oldAttachment.caption,
captionBodyRanges: try captionBodyRanges.map { try JSONEncoder().encode($0) },
sourceFilename: oldAttachment.sourceFilename,
sourceUnencryptedByteCount: oldAttachment.byteCount,
sourceMediaHeightPixels: sourceMediaHeightPixels,
sourceMediaWidthPixels: sourceMediaWidthPixels
)
try reference.insert(tx.database)
}
}
}
extension TSAttachmentMigration.SerializedStoryMessageAttachment {
var tsAttachmentUniqueId: String? {
switch self {
case .file(let attachmentId):
return attachmentId
case .text(let textAttachment):
return textAttachment.preview?.imageAttachmentId
case .fileV2(let attachment):
return attachment.attachmentId
case .foreignReferenceAttachment:
return nil
}
}
}

View File

@ -1,239 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
/// Migration for thread wallpapers, which were previously represented as an unencrypted
/// file on disk in a particular folder with the thread unique id as the file name.
/// After migrating they are full-fledged v2 attachments in the ThreadAttachmentReference table.
///
/// The migration works in two passes which must happen in separate write transactions but
/// must be run back to back. (Why? Filesystem changes are not part of the db transaction,
/// so we need to first "reserve" the final file location in the db and then write the file. If the latter
/// step fails we will just rewrite to the same file location next time we retry.)
///
/// Phase 1: Read the key value store for threads with image wallpapers, for each one create
/// a _new_ key value store entry with the "reserved" (random) final file location.
///
/// Phase 2: for each reserved file location, do image validation and create the v2 attachments.
/// Delete the originals.
extension TSAttachmentMigration {
fileprivate static let legacyWallpaperDirectory = URL(
fileURLWithPath: "Wallpapers",
isDirectory: true,
relativeTo: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: TSConstants.applicationGroup)!
)
/// First pass
static func prepareThreadWallpaperMigration(tx: DBWriteTransaction) throws {
let rows = try Row.fetchAll(
tx.database,
sql: "SELECT * FROM keyvalue WHERE collection = ?",
arguments: ["Wallpaper+Enum"]
)
for row in rows {
guard
let valueData = row["value"] as? Data,
let valueDecoded = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSString.self, from: valueData),
valueDecoded == "photo",
// Actually either thread unique id or "global";
// we replicate that in our new entry.
let threadUniqueId = row["key"] as? String
else {
continue
}
let reservedFileUUID = UUID().uuidString
// Reinsert with the new collection name.
try tx.database.execute(
sql: """
INSERT INTO keyvalue
(collection, key, value)
VALUES (?, ?, ?)
""",
arguments: ["WallpaperMigration", threadUniqueId, reservedFileUUID]
)
}
}
/// Second pass
static func completeThreadWallpaperMigration(tx: DBWriteTransaction) throws {
let reservedFileRows = try Row.fetchAll(
tx.database,
sql: "SELECT * FROM keyvalue WHERE collection = ?",
arguments: ["WallpaperMigration"]
)
for row in reservedFileRows {
guard
let reservedFileUUIDRaw = row["value"] as? String,
let reservedFileUUID = UUID(uuidString: reservedFileUUIDRaw),
let threadUniqueIdOrGlobal = row["key"] as? String
else {
continue
}
// Nil for the global thread wallpaper.
let threadRowId: Int64?
let wallpaperFilename: String
if threadUniqueIdOrGlobal == "global" {
threadRowId = nil
wallpaperFilename = "global"
} else {
guard
let fetchedThreadRowId = try Int64.fetchOne(
tx.database,
sql: "SELECT id FROM model_TSThread WHERE uniqueId = ?",
arguments: [threadUniqueIdOrGlobal]
),
let filename = threadUniqueIdOrGlobal.addingPercentEncoding(withAllowedCharacters: .alphanumerics)
else {
continue
}
threadRowId = fetchedThreadRowId
wallpaperFilename = filename
}
do {
try Self.migrateSingleWallpaper(
reservedFileUUID: reservedFileUUID,
threadRowId: threadRowId,
wallpaperFilename: wallpaperFilename,
tx: tx
)
} catch {
// Ignore failues on individual wallpapers; we'll just drop them if they're unable
// to get migrated. But delete at the reserved file location just in case.
try OWSFileSystem.deleteFileIfExists(
url: TSAttachmentMigration.V2Attachment.absoluteAttachmentFileURL(
relativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(reservedUUID: reservedFileUUID)
)
)
}
}
// Delete our reserved rows.
try tx.database.execute(
sql: "DELETE FROM keyvalue where collection = ?",
arguments: ["WallpaperMigration"]
)
}
// Delete the old wallpaper directory.
static func cleanUpLegacyThreadWallpaperDirectory() throws {
for filePath in try OWSFileSystem.recursiveFilesInDirectory(legacyWallpaperDirectory.path) {
if !OWSFileSystem.deleteFile(filePath, ignoreIfMissing: true) {
throw OWSAssertionError("Failed to delete!")
}
}
}
private static func migrateSingleWallpaper(
reservedFileUUID: UUID,
threadRowId: Int64?,
wallpaperFilename: String,
tx: DBWriteTransaction
) throws {
let wallpaperFile = URL(
fileURLWithPath: wallpaperFilename,
isDirectory: false,
relativeTo: legacyWallpaperDirectory
)
let reservedFileIds = TSAttachmentMigration.V2AttachmentContentValidator.ReservedRelativeFileIds(
primaryFile: reservedFileUUID,
audioWaveform: UUID() /* unused */,
videoStillFrame: UUID() /* unused */
)
let pendingAttachment: TSAttachmentMigration.PendingV2AttachmentFile
do {
pendingAttachment = try TSAttachmentMigration.V2AttachmentContentValidator.validateContents(
unencryptedFileUrl: wallpaperFile,
reservedFileIds: reservedFileIds,
attachmentKey: nil,
mimeType: "image/jpeg",
renderingFlag: .default,
sourceFilename: nil
)
} catch {
guard
let imageData = try? Data(contentsOf: wallpaperFile),
let image = UIImage(data: imageData),
let resizedImage = image.resized(maxDimensionPixels: CGFloat(TSAttachmentMigration.kMaxStillImageDimensions)),
let resizedImageData = resizedImage.jpegData(compressionQuality: 0.8)
else {
// We can't get the image to resize.
// Just drop it; the wallpaper will be lost.
return
}
// Try again with a resized image.
let resizedImageFile = OWSFileSystem.temporaryFileUrl(isAvailableWhileDeviceLocked: true)
try resizedImageData.write(to: resizedImageFile)
pendingAttachment = try TSAttachmentMigration.V2AttachmentContentValidator.validateContents(
unencryptedFileUrl: resizedImageFile,
reservedFileIds: reservedFileIds,
attachmentKey: nil,
mimeType: "image/jpeg",
renderingFlag: .default,
sourceFilename: nil
)
}
let v2AttachmentId: Int64
if
let existingV2Attachment = try TSAttachmentMigration.V2Attachment
.filter(Column("sha256ContentHash") == pendingAttachment.sha256ContentHash)
.fetchOne(tx.database)
{
// If we already have a v2 attachment with the same plaintext hash,
// create new references to it and drop the pending attachment.
v2AttachmentId = existingV2Attachment.id!
// Delete the reserved files being used by the pending attachment.
try OWSFileSystem.deleteFileIfExists(
url: TSAttachmentMigration.V2Attachment.absoluteAttachmentFileURL(
relativeFilePath: TSAttachmentMigration.V2Attachment.relativeFilePath(
reservedUUID: reservedFileUUID
)
)
)
} else {
var v2Attachment = TSAttachmentMigration.V2Attachment(
blurHash: pendingAttachment.blurHash,
sha256ContentHash: pendingAttachment.sha256ContentHash,
encryptedByteCount: pendingAttachment.encryptedByteCount,
unencryptedByteCount: pendingAttachment.unencryptedByteCount,
mimeType: pendingAttachment.mimeType,
encryptionKey: pendingAttachment.encryptionKey,
digestSHA256Ciphertext: pendingAttachment.digestSHA256Ciphertext,
contentType: UInt32(pendingAttachment.validatedContentType.rawValue),
transitCdnNumber: nil,
transitCdnKey: nil,
transitUploadTimestamp: nil,
transitEncryptionKey: nil,
transitUnencryptedByteCount: nil,
transitDigestSHA256Ciphertext: nil,
lastTransitDownloadAttemptTimestamp: nil,
localRelativeFilePath: pendingAttachment.localRelativeFilePath,
cachedAudioDurationSeconds: pendingAttachment.audioDurationSeconds,
cachedMediaHeightPixels: pendingAttachment.mediaSizePixels.map { UInt32($0.height) },
cachedMediaWidthPixels: pendingAttachment.mediaSizePixels.map { UInt32($0.width) },
cachedVideoDurationSeconds: pendingAttachment.videoDurationSeconds,
audioWaveformRelativeFilePath: pendingAttachment.audioWaveformRelativeFilePath,
videoStillFrameRelativeFilePath: pendingAttachment.videoStillFrameRelativeFilePath
)
try v2Attachment.insert(tx.database)
v2AttachmentId = v2Attachment.id!
}
let reference = TSAttachmentMigration.ThreadAttachmentReference(
ownerRowId: threadRowId,
attachmentRowId: v2AttachmentId,
creationTimestamp: Date().ows_millisecondsSince1970
)
try reference.insert(tx.database)
}
}

View File

@ -1,10 +0,0 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import GRDB
/// Just a namespace for TSAttachment migration related types.
public enum TSAttachmentMigration {}

View File

@ -16,7 +16,6 @@ public class MockSSKEnvironment {
callMessageHandler: any CallMessageHandler = NoopCallMessageHandler(),
currentCallProvider: any CurrentCallProvider = CurrentCallNoOpProvider(),
notificationPresenter: any NotificationPresenter = NoopNotificationPresenterImpl(),
incrementalMessageTSAttachmentMigratorFactory: any IncrementalMessageTSAttachmentMigratorFactory = IncrementalMessageTSAttachmentMigratorFactoryMock(),
testDependencies: AppSetup.TestDependencies? = nil
) async {
owsPrecondition(!(CurrentAppContext() is TestAppContext))
@ -51,7 +50,6 @@ public class MockSSKEnvironment {
callMessageHandler: callMessageHandler,
currentCallProvider: currentCallProvider,
notificationPresenter: notificationPresenter,
incrementalMessageTSAttachmentMigratorFactory: incrementalMessageTSAttachmentMigratorFactory,
testDependencies: testDependencies ?? AppSetup.TestDependencies(
contactManager: FakeContactsManager(),
groupV2Updates: MockGroupV2Updates(),

View File

@ -332,7 +332,6 @@ class BackupArchiveIntegrationTests: XCTestCase {
callMessageHandler: CrashyMocks.MockCallMessageHandler(),
currentCallProvider: CrashyMocks.MockCurrentCallThreadProvider(),
notificationPresenter: CrashyMocks.MockNotificationPresenter(),
incrementalMessageTSAttachmentMigratorFactory: NoOpIncrementalMessageTSAttachmentMigratorFactory(),
testDependencies: AppSetup.TestDependencies(
backupAttachmentCoordinator: MockBackupAttachmentCoordinator(),
dateProvider: dateProvider,

View File

@ -98,7 +98,6 @@ public class ShareViewController: OWSNavigationController, ShareViewDelegate, SA
callMessageHandler: NoopCallMessageHandler(),
currentCallProvider: CurrentCallNoOpProvider(),
notificationPresenter: NoopNotificationPresenterImpl(),
incrementalMessageTSAttachmentMigratorFactory: NoOpIncrementalMessageTSAttachmentMigratorFactory(),
)
// Configure the rest of the globals before preparing the database.

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
public import AVFoundation
public import SignalServiceKit
/// Represents an attachment the user *might* choose to send.

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import AVFoundation
public import SignalServiceKit
/// Represents an attachment that's fully valid and ready to send.