Drop TSAttachment migration
Co-authored-by: sashaweiss-signal <sasha@signal.org> Co-authored-by: Max Radermacher <max@signal.org>
This commit is contained in:
parent
8c3fbbdca3
commit
2eba0c6360
@ -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 */,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
<array>
|
||||
<string>AttachmentValidationBackfillMigrator</string>
|
||||
<string>BackupBGProcessingTaskRunner</string>
|
||||
<string>MessageAttachmentMigrationTask</string>
|
||||
<string>MessageFetchBGRefreshTask</string>
|
||||
<string>LazyDatabaseMigratorTask</string>
|
||||
</array>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -70,7 +70,6 @@ class NSEEnvironment {
|
||||
callMessageHandler: NSECallMessageHandler(),
|
||||
currentCallProvider: CurrentCallNoOpProvider(),
|
||||
notificationPresenter: NotificationPresenterImpl(),
|
||||
incrementalMessageTSAttachmentMigratorFactory: NoOpIncrementalMessageTSAttachmentMigratorFactory(),
|
||||
)
|
||||
.migrateDatabaseData()
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -301,8 +301,6 @@ public extension DatabaseRecovery {
|
||||
QueuedBackupStickerPackDownload.databaseTableName,
|
||||
OrphanedBackupAttachment.databaseTableName,
|
||||
"MessageBackupAvatarFetchQueue",
|
||||
"model_TSAttachment",
|
||||
"TSAttachmentMigration",
|
||||
"AvatarDefaultColor",
|
||||
GroupMessageProcessorJob.databaseTableName,
|
||||
"ListedBackupMediaObject",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user