Further work on migrations and message pipeline refactoring

Refactored the AppDelegate from Objective C to Swift
Updated the HomeVC to use GRDB
Refactored a number of the Job types to be driven via GRDB and the new JobRunner
Fixed a bug where the LinkPreviewView wouldn't render correctly in dark mode
This commit is contained in:
Morgan Pretty 2022-04-21 16:42:35 +10:00
parent 28553b218b
commit 11231599db
135 changed files with 9073 additions and 3778 deletions

View File

@ -50,6 +50,7 @@ abstract_target 'GlobalDependencies' do
pod 'SAMKeychain'
pod 'SwiftProtobuf', '~> 1.5.0'
pod 'YYImage', git: 'https://github.com/signalapp/YYImage'
pod 'DifferenceKit'
end
target 'SessionMessagingKit' do

View File

@ -219,6 +219,6 @@ SPEC CHECKSUMS:
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: 33a5ecfe231383831bf212de4ff6c99c047c344a
PODFILE CHECKSUM: 50ae96076a7cd581c63b3276679615844c88ac44
COCOAPODS: 1.11.2

View File

@ -121,7 +121,6 @@
70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; };
768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; };
76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; };
76EB054018170B33006006FC /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 76EB03C318170B33006006FC /* AppDelegate.m */; };
7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1581E1271E743B00848B49 /* OWSSounds.swift */; };
7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; };
7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; };
@ -142,7 +141,6 @@
A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; };
A1C32D5017A06538000A904E /* AddressBookUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4F17A06537000A904E /* AddressBookUI.framework */; };
A1C32D5117A06544000A904E /* AddressBook.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A1C32D4D17A0652C000A904E /* AddressBook.framework */; };
A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = A5509EC91A69AB8B00ABA4BC /* Main.storyboard */; };
B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B66DBF4919D5BBC8006EA940 /* Images.xcassets */; };
B67EBF5D19194AC60084CCFD /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = B67EBF5C19194AC60084CCFD /* Settings.bundle */; };
B6B226971BE4B7D200860F4D /* ContactsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6B226961BE4B7D200860F4D /* ContactsUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
@ -303,7 +301,6 @@
C32C59C5256DB41F003C73A2 /* TSGroupModel.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB0A255A580700E217F9 /* TSGroupModel.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C59C6256DB41F003C73A2 /* TSGroupThread.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA79255A57FB00E217F9 /* TSGroupThread.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C59C7256DB41F003C73A2 /* TSThread.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBB8255A581600E217F9 /* TSThread.m */; };
C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */; };
C32C5A13256DB7A5003C73A2 /* PushNotificationAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */; };
C32C5A24256DB7DB003C73A2 /* SNUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB6B255A580F00E217F9 /* SNUserDefaults.swift */; };
C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBE1255A581A00E217F9 /* LKGroupUtilities.m */; };
@ -343,7 +340,6 @@
C32C5BE6256DC891003C73A2 /* OWSReadReceiptManager.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB1D255A580900E217F9 /* OWSReadReceiptManager.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C5BEF256DC8EE003C73A2 /* OWSDisappearingMessagesJob.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDBDD255A581900E217F9 /* OWSDisappearingMessagesJob.m */; };
C32C5BF8256DC8F6003C73A2 /* OWSDisappearingMessagesJob.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDA80255A57FC00E217F9 /* OWSDisappearingMessagesJob.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C5C24256DCB30003C73A2 /* NotificationsProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C5C3D256DCBAF003C73A2 /* AppReadiness.m in Sources */ = {isa = PBXBuildFile; fileRef = C33FDB75255A581000E217F9 /* AppReadiness.m */; };
C32C5C46256DCBB2003C73A2 /* AppReadiness.h in Headers */ = {isa = PBXBuildFile; fileRef = C33FDB01255A580700E217F9 /* AppReadiness.h */; settings = {ATTRIBUTES = (Public, ); }; };
C32C5C4F256DCC36003C73A2 /* Storage+OpenGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */; };
@ -471,11 +467,11 @@
C3471F4C25553AB000297E91 /* MessageReceiver+Decryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */; };
C34A977425A3E34A00852C71 /* ClosedGroupControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */; };
C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */; };
C352A2F525574B4700338F3E /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* Job.swift */; };
C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2F425574B4700338F3E /* LegacyJob.swift */; };
C352A2FF25574B6300338F3E /* MessageSendJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A2FE25574B6300338F3E /* MessageSendJob.swift */; };
C352A30925574D8500338F3E /* Message+Destination.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A30825574D8400338F3E /* Message+Destination.swift */; };
C352A31325574F5200338F3E /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */; };
C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
C352A349255781F400338F3E /* AttachmentDownloadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A348255781F400338F3E /* AttachmentDownloadJob.swift */; };
C352A35B2557824E00338F3E /* AttachmentUploadJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */; };
C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */ = {isa = PBXBuildFile; fileRef = C352A36C2557858D00338F3E /* NSTimer+Proxying.m */; };
@ -520,9 +516,7 @@
C38EF276255B6D7A007E1867 /* OWSDatabaseMigration.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF270255B6D79007E1867 /* OWSDatabaseMigration.m */; };
C38EF277255B6D7A007E1867 /* OWSDatabaseMigration.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF271255B6D79007E1867 /* OWSDatabaseMigration.h */; settings = {ATTRIBUTES = (Public, ); }; };
C38EF28F255B6D86007E1867 /* VersionMigrations.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF283255B6D84007E1867 /* VersionMigrations.h */; settings = {ATTRIBUTES = (Public, ); }; };
C38EF290255B6D86007E1867 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; };
C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF286255B6D85007E1867 /* VersionMigrations.m */; };
C38EF293255B6D86007E1867 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; };
C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A2255B6D93007E1867 /* Identicon+ObjC.swift */; };
C38EF2A6255B6D93007E1867 /* PlaceholderIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A3255B6D93007E1867 /* PlaceholderIcon.swift */; };
C38EF2A7255B6D93007E1867 /* ProfilePictureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF2A4255B6D93007E1867 /* ProfilePictureView.swift */; };
@ -671,7 +665,6 @@
C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */; };
C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; };
C3C2A75F2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */; };
C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */; };
C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7702553A41E00C340D1 /* ControlMessage.swift */; };
C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7822553AAF200C340D1 /* SNProto.swift */; };
C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */; };
@ -724,7 +717,6 @@
D2179CFE16BB0B480006F3AB /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2179CFD16BB0B480006F3AB /* SystemConfiguration.framework */; };
D221A08E169C9E5E00537ABF /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08D169C9E5E00537ABF /* UIKit.framework */; };
D221A090169C9E5E00537ABF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A08F169C9E5E00537ABF /* Foundation.framework */; };
D221A09A169C9E5E00537ABF /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = D221A099169C9E5E00537ABF /* main.m */; };
D221A0E8169DFFC500537ABF /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D221A0E7169DFFC500537ABF /* AVFoundation.framework */; };
D24B5BD5169F568C00681372 /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D24B5BD4169F568C00681372 /* AudioToolbox.framework */; };
D2AEACDC16C426DA00C364C0 /* CFNetwork.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D2AEACDB16C426DA00C364C0 /* CFNetwork.framework */; };
@ -757,7 +749,7 @@
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799627FFA84900936362 /* RecipientState.swift */; };
FD09799927FFC1A300936362 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799827FFC1A300936362 /* Attachment.swift */; };
FD09799B27FFC82D00936362 /* Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09799A27FFC82D00936362 /* Quote.swift */; };
FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */; };
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; };
FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; };
FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; };
FD17D7A127F40D2500122BE0 /* GRDBStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD28A4F527EAD44C00FF65E7 /* GRDBStorage.swift */; };
@ -801,8 +793,31 @@
FD859F0027C4691300510D0C /* MockDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD859EFF27C4691300510D0C /* MockDataGenerator.swift */; };
FD88BAD927A7439C00BBC442 /* MessageRequestsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */; };
FD88BADB27A750F200BBC442 /* MessageRequestsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */; };
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */; };
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */; };
FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = C38EF287255B6D85007E1867 /* AppSetup.m */; };
FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF284255B6D84007E1867 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; };
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */; };
FDC4389E27BA2B8A00C60D73 /* SessionUtilitiesKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A679255388CC00C340D1 /* SessionUtilitiesKit.framework */; };
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */; };
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; };
FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */; };
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; };
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; };
FDF0B740280402C4004C14C5 /* Job.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73F280402C4004C14C5 /* Job.swift */; };
FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; };
FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7432804EF1B004C14C5 /* JobRunner.swift */; };
FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */; };
FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; };
FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; };
FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */; };
FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */; };
FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; };
FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */; };
FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */; };
FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */; };
FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */; };
FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B75D280AAF35004C14C5 /* Preferences.swift */; };
FDFD645827EC1F4000808CA1 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645727EC1F4000808CA1 /* Atomic.swift */; };
/* End PBXBuildFile section */
@ -1120,8 +1135,6 @@
748A5CAEDD7C919FC64C6807 /* Pods_SignalTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SignalTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
768A1A2A17FC9CD300E00ED8 /* libz.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libz.dylib; path = usr/lib/libz.dylib; sourceTree = SDKROOT; };
76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaPlayer.framework; path = System/Library/Frameworks/MediaPlayer.framework; sourceTree = SDKROOT; };
76EB03C218170B33006006FC /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = "<group>"; };
76EB03C318170B33006006FC /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = "<group>"; };
7ABE4694B110C1BBCB0E46A2 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SignalUtilitiesKit.app store release.xcconfig"; sourceTree = "<group>"; };
7B1581E1271E743B00848B49 /* OWSSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSSounds.swift; sourceTree = "<group>"; };
7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; };
@ -1157,7 +1170,6 @@
A1C32D4D17A0652C000A904E /* AddressBook.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBook.framework; path = System/Library/Frameworks/AddressBook.framework; sourceTree = SDKROOT; };
A1C32D4F17A06537000A904E /* AddressBookUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AddressBookUI.framework; path = System/Library/Frameworks/AddressBookUI.framework; sourceTree = SDKROOT; };
A1FDCBEE16DAA6C300868894 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
A5509EC91A69AB8B00ABA4BC /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = "<group>"; };
A5C037C0D2746ABEE2684E70 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SignalUtilitiesKit.debug.xcconfig"; sourceTree = "<group>"; };
A6344D429FFAC3B44E6A06FA /* Pods-SessionSnodeKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionSnodeKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SessionSnodeKit/Pods-SessionSnodeKit.debug.xcconfig"; sourceTree = "<group>"; };
A9F14F620D87A5BA98DDB608 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
@ -1282,7 +1294,6 @@
B8D8F17625661AFA0092EF10 /* Storage+Jobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Jobs.swift"; sourceTree = "<group>"; };
B8D8F18825661BA50092EF10 /* Storage+OpenGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+OpenGroups.swift"; sourceTree = "<group>"; };
B8D8F19225661BF80092EF10 /* Storage+Messaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Storage+Messaging.swift"; sourceTree = "<group>"; };
B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "MessageSender+Convenience.swift"; path = "../../SignalUtilitiesKit/Messaging/Sending & Receiving/MessageSender+Convenience.swift"; sourceTree = "<group>"; };
B8EB20E6263F7E4B00773E52 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Localizable.strings; sourceTree = "<group>"; };
B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+OpenGroupInvitation.swift"; sourceTree = "<group>"; };
B8EB20EF2640F7F000773E52 /* OpenGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupInvitationView.swift; sourceTree = "<group>"; };
@ -1448,7 +1459,6 @@
C33FDB75255A581000E217F9 /* AppReadiness.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppReadiness.m; sourceTree = "<group>"; };
C33FDB77255A581000E217F9 /* NSUserDefaults+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSUserDefaults+OWS.m"; sourceTree = "<group>"; };
C33FDB78255A581000E217F9 /* OWSOperation.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OWSOperation.m; sourceTree = "<group>"; };
C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NotificationsProtocol.h; sourceTree = "<group>"; };
C33FDB7F255A581100E217F9 /* FullTextSearchFinder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullTextSearchFinder.swift; sourceTree = "<group>"; };
C33FDB80255A581100E217F9 /* Notification+Loki.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Notification+Loki.swift"; sourceTree = "<group>"; };
C33FDB81255A581100E217F9 /* UIImage+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIImage+OWS.m"; sourceTree = "<group>"; };
@ -1505,11 +1515,11 @@
C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MessageReceiver+Decryption.swift"; sourceTree = "<group>"; };
C34A977325A3E34A00852C71 /* ClosedGroupControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClosedGroupControlMessage.swift; sourceTree = "<group>"; };
C34C8F7323A7830A00D82669 /* SpaceMono-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SpaceMono-Bold.ttf"; sourceTree = "<group>"; };
C352A2F425574B4700338F3E /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = "<group>"; };
C352A2F425574B4700338F3E /* LegacyJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyJob.swift; sourceTree = "<group>"; };
C352A2FE25574B6300338F3E /* MessageSendJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJob.swift; sourceTree = "<group>"; };
C352A30825574D8400338F3E /* Message+Destination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Message+Destination.swift"; sourceTree = "<group>"; };
C352A31225574F5200338F3E /* MessageReceiveJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiveJob.swift; sourceTree = "<group>"; };
C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyPNServerJob.swift; sourceTree = "<group>"; };
C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotifyPushServerJob.swift; sourceTree = "<group>"; };
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloadJob.swift; sourceTree = "<group>"; };
C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadJob.swift; sourceTree = "<group>"; };
C352A36C2557858D00338F3E /* NSTimer+Proxying.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSTimer+Proxying.m"; sourceTree = "<group>"; };
@ -1728,7 +1738,6 @@
C3C2A74C2553A39700C340D1 /* VisibleMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibleMessage.swift; sourceTree = "<group>"; };
C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Quote.swift"; sourceTree = "<group>"; };
C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+LinkPreview.swift"; sourceTree = "<group>"; };
C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Contact.swift"; sourceTree = "<group>"; };
C3C2A7702553A41E00C340D1 /* ControlMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessage.swift; sourceTree = "<group>"; };
C3C2A7822553AAF200C340D1 /* SNProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SNProto.swift; sourceTree = "<group>"; };
C3C2A7832553AAF300C340D1 /* SessionProtos.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SessionProtos.pb.swift; sourceTree = "<group>"; };
@ -1766,7 +1775,6 @@
D221A08F169C9E5E00537ABF /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
D221A091169C9E5E00537ABF /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
D221A095169C9E5E00537ABF /* Session-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Session-Info.plist"; sourceTree = "<group>"; };
D221A099169C9E5E00537ABF /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
D221A09B169C9E5E00537ABF /* Session-Prefix.pch */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Session-Prefix.pch"; sourceTree = "<group>"; };
D221A0E7169DFFC500537ABF /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = ../../../../../../System/Library/Frameworks/AVFoundation.framework; sourceTree = "<group>"; };
D24B5BD4169F568C00681372 /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = ../../../../../../System/Library/Frameworks/AudioToolbox.framework; sourceTree = "<group>"; };
@ -1810,7 +1818,7 @@
FD09799827FFC1A300936362 /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
FD09799A27FFC82D00936362 /* Quote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Quote.swift; sourceTree = "<group>"; };
FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = "<group>"; };
FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = "<group>"; };
FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = "<group>"; };
FD17D7A327F40F8100122BE0 /* _002_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
@ -1854,7 +1862,28 @@
FD88BAD827A7439C00BBC442 /* MessageRequestsCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsCell.swift; sourceTree = "<group>"; };
FD88BADA27A750F200BBC442 /* MessageRequestsMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsMigration.swift; sourceTree = "<group>"; };
FD9039443F7CB729CF71350E /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-SessionNotificationServiceExtension.debug.xcconfig"; sourceTree = "<group>"; };
FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedMessagesJob.swift; sourceTree = "<group>"; };
FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FailedAttachmentDownloadsJob.swift; sourceTree = "<group>"; };
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Utilities.swift"; sourceTree = "<group>"; };
FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Differentiable+Utilities.swift"; sourceTree = "<group>"; };
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; };
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = "<group>"; };
FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreview.swift; sourceTree = "<group>"; };
FDF0B73F280402C4004C14C5 /* Job.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Job.swift; sourceTree = "<group>"; };
FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _002_SetupStandardJobs.swift; sourceTree = "<group>"; };
FDF0B7432804EF1B004C14C5 /* JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunner.swift; sourceTree = "<group>"; };
FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisappearingMessagesJob.swift; sourceTree = "<group>"; };
FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyModel.swift; sourceTree = "<group>"; };
FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = "<group>"; };
FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PersistableRecord+Utilities.swift"; sourceTree = "<group>"; };
FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendReadReceiptsJob.swift; sourceTree = "<group>"; };
FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = "<group>"; };
FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEnvironment.swift; sourceTree = "<group>"; };
FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverError.swift; sourceTree = "<group>"; };
FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderError.swift; sourceTree = "<group>"; };
FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Convenience.swift"; sourceTree = "<group>"; };
FDF0B75D280AAF35004C14C5 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
FDFD645727EC1F4000808CA1 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = "<group>"; };
FEDBAE1B98C49BBE8C87F575 /* Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; path = "Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit.debug.xcconfig"; sourceTree = "<group>"; };
FF9BA33D021B115B1F5B4E46 /* Pods-SessionMessagingKit.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SessionMessagingKit.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SessionMessagingKit/Pods-SessionMessagingKit.app store release.xcconfig"; sourceTree = "<group>"; };
@ -2483,7 +2512,6 @@
C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */,
C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */,
C3C2A75E2553A3C500C340D1 /* VisibleMessage+LinkPreview.swift */,
C3C2A7672553A3D900C340D1 /* VisibleMessage+Contact.swift */,
C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */,
B8EB20ED2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift */,
);
@ -2510,6 +2538,7 @@
C300A5F02554B08500555489 /* Sending & Receiving */ = {
isa = PBXGroup;
children = (
FDF0B7562807F35E004C14C5 /* Errors */,
C3D9E3B52567685D0040E4F3 /* Attachments */,
B8F5F61925EDE4B0003BF8D4 /* Data Extraction */,
C32C5B01256DC054003C73A2 /* Expiration */,
@ -2523,7 +2552,7 @@
B8D0A25825E367AC00C1835E /* Notification+MessageReceiver.swift */,
C300A5F12554B09800555489 /* MessageSender.swift */,
C38D5E8C2575011E00B6A65C /* MessageSender+ClosedGroups.swift */,
B8D8F1EF256621180092EF10 /* MessageSender+Convenience.swift */,
FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */,
C3471ECA2555356A00297E91 /* MessageSender+Encryption.swift */,
C300A5FB2554B0A000555489 /* MessageReceiver.swift */,
C3471F4B25553AB000297E91 /* MessageReceiver+Decryption.swift */,
@ -2668,6 +2697,7 @@
children = (
C38EF398255B6DD9007E1867 /* OWSQuotedReplyModel.h */,
C38EF39A255B6DD9007E1867 /* OWSQuotedReplyModel.m */,
FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */,
B840729F2565F1670037CB17 /* OWSQuotedReplyModel+Conversion.swift */,
C33FDAD5255A580300E217F9 /* TSQuotedMessage.h */,
C33FDB83255A581100E217F9 /* TSQuotedMessage.m */,
@ -2828,14 +2858,14 @@
C352A2F325574B3300338F3E /* Jobs */ = {
isa = PBXGroup;
children = (
C352A2F425574B4700338F3E /* Job.swift */,
FDF0B7452804F0A8004C14C5 /* Types */,
FDF0B7432804EF1B004C14C5 /* JobRunner.swift */,
FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */,
C352A2F425574B4700338F3E /* LegacyJob.swift */,
C352A3922557883D00338F3E /* JobDelegate.swift */,
C352A3882557876500338F3E /* JobQueue.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
C352A35A2557824E00338F3E /* AttachmentUploadJob.swift */,
C352A31225574F5200338F3E /* MessageReceiveJob.swift */,
C352A2FE25574B6300338F3E /* MessageSendJob.swift */,
C352A32E2557549C00338F3E /* NotifyPNServerJob.swift */,
C352A348255781F400338F3E /* AttachmentDownloadJob.swift */,
);
path = Jobs;
sourceTree = "<group>";
@ -2864,6 +2894,7 @@
7BA7F4B9279F9F3700B3A466 /* GlobalSearch */,
FD659ABE27A7648200F12C02 /* Message Requests */,
FD88BAD727A7438E00BBC442 /* Views */,
FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */,
B8BB82A4238F627000BA5194 /* HomeVC.swift */,
B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */,
);
@ -3034,7 +3065,7 @@
C379DC6825672B5E0002D4EB /* Notifications */ = {
isa = PBXGroup;
children = (
C33FDB7A255A581000E217F9 /* NotificationsProtocol.h */,
FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */,
C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */,
);
path = Notifications;
@ -3212,6 +3243,7 @@
C33FDB71255A581000E217F9 /* OWSMediaGalleryFinder.m */,
C38EF2F1255B6DBB007E1867 /* OWSPreferences.h */,
C38EF308255B6DBE007E1867 /* OWSPreferences.m */,
FDF0B75D280AAF35004C14C5 /* Preferences.swift */,
C38EF288255B6D85007E1867 /* OWSSounds.h */,
C38EF28B255B6D86007E1867 /* OWSSounds.m */,
7B1581E1271E743B00848B49 /* OWSSounds.swift */,
@ -3225,6 +3257,7 @@
C38EEF09255B49A8007E1867 /* SNProtoEnvelope+Conversion.swift */,
C33FDB31255A580A00E217F9 /* SSKEnvironment.h */,
C33FDAF4255A580600E217F9 /* SSKEnvironment.m */,
FDF0B7542807C4BB004C14C5 /* SSKEnvironment.swift */,
C33FDB32255A580A00E217F9 /* SSKIncrementingIdFinder.swift */,
C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */,
C3ECBF7A257056B700EA7FCE /* Threading.swift */,
@ -3364,8 +3397,11 @@
C3CA3B11255CF17200F4C6D4 /* Utilities */ = {
isa = PBXGroup;
children = (
C38EF284255B6D84007E1867 /* AppSetup.h */,
C38EF287255B6D85007E1867 /* AppSetup.m */,
C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */,
C38EF2F0255B6DBB007E1867 /* OWSAnyTouchGestureRecognizer.m */,
FDCDB8DD2810F73B00352A0C /* Differentiable+Utilities.swift */,
C38EF2ED255B6DBB007E1867 /* DisplayableText.swift */,
C38EF3DC255B6DF1007E1867 /* DirectionalPanGestureRecognizer.swift */,
C38EF240255B6D67007E1867 /* UIView+OWS.swift */,
@ -3403,8 +3439,6 @@
C38EF2F2255B6DBC007E1867 /* Searcher.swift */,
B8C2B33B2563770800551B4D /* ThreadUtil.h */,
B8C2B331256376F000551B4D /* ThreadUtil.m */,
C38EF284255B6D84007E1867 /* AppSetup.h */,
C38EF287255B6D85007E1867 /* AppSetup.m */,
B8856D5F256F129B001CE70E /* OWSAlerts.swift */,
C3F0A52F255C80BC007BE2A3 /* NoopNotificationsManager.swift */,
C38EF283255B6D84007E1867 /* VersionMigrations.h */,
@ -3462,8 +3496,6 @@
C3F0A58F255C8E3D007BE2A3 /* Meta */ = {
isa = PBXGroup;
children = (
76EB03C218170B33006006FC /* AppDelegate.h */,
76EB03C318170B33006006FC /* AppDelegate.m */,
C3AAFFF125AE99710089E6DD /* AppDelegate.swift */,
34D99CE3217509C1000AFB39 /* AppEnvironment.swift */,
B81D260326158DF5004D1FE1 /* Certificates */,
@ -3471,8 +3503,6 @@
34330A581E7875FB00DF2FB9 /* Fonts */,
B66DBF4919D5BBC8006EA940 /* Images.xcassets */,
45CB2FA71CB7146C00E1B343 /* Launch Screen.storyboard */,
A5509EC91A69AB8B00ABA4BC /* Main.storyboard */,
D221A099169C9E5E00537ABF /* main.m */,
34B0796C1FCF46B000E248C2 /* MainAppContext.h */,
34B0796B1FCF46B000E248C2 /* MainAppContext.m */,
C3CA3AA0255CDA7000F4C6D4 /* Mnemonic */,
@ -3604,6 +3634,7 @@
FD09797127FAA2F500936362 /* Optional+Utilities.swift */,
FD09797C27FBDB2000936362 /* Notification+Utilities.swift */,
C3E7134E251C867C009649BB /* Sodium+Conversion.swift */,
FDA8EB0F280F8238002B68E5 /* Codable+Utilities.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -3622,9 +3653,12 @@
FD09798A27FD1CFE00936362 /* Capability.swift */,
FD09799227FE693200936362 /* Interaction.swift */,
FD09799627FFA84900936362 /* RecipientState.swift */,
FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */,
FD09799827FFC1A300936362 /* Attachment.swift */,
FD09799A27FFC82D00936362 /* Quote.swift */,
FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */,
FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */,
FDF0B73F280402C4004C14C5 /* Job.swift */,
);
path = Models;
sourceTree = "<group>";
@ -3633,7 +3667,8 @@
isa = PBXGroup;
children = (
FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */,
FD17D79827F40AB800122BE0 /* _002_YDBToGRDBMigration.swift */,
FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */,
FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */,
);
path = Migrations;
sourceTree = "<group>";
@ -3710,6 +3745,7 @@
FD17D7C227F5204C00122BE0 /* Database+Utilities.swift */,
FD17D7C627F5207C00122BE0 /* DatabaseMigrator+Utilities.swift */,
FD17D7BC27F51F6900122BE0 /* GRDB+Notifications.swift */,
FDF0B74C280664E9004C14C5 /* PersistableRecord+Utilities.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -3775,6 +3811,29 @@
path = Views;
sourceTree = "<group>";
};
FDF0B7452804F0A8004C14C5 /* Types */ = {
isa = PBXGroup;
children = (
FDF0B7462804F0CE004C14C5 /* DisappearingMessagesJob.swift */,
FDA8EAFD280E8B78002B68E5 /* FailedMessagesJob.swift */,
FDA8EAFF280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift */,
C352A2FE25574B6300338F3E /* MessageSendJob.swift */,
C352A31225574F5200338F3E /* MessageReceiveJob.swift */,
C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */,
FDF0B74E28079E5E004C14C5 /* SendReadReceiptsJob.swift */,
);
path = Types;
sourceTree = "<group>";
};
FDF0B7562807F35E004C14C5 /* Errors */ = {
isa = PBXGroup;
children = (
FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */,
FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */,
);
path = Errors;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
@ -3800,6 +3859,7 @@
C33FDD7C255A582000E217F9 /* SSKAsserts.h in Headers */,
C38EF3F6255B6DF7007E1867 /* OWSTextView.h in Headers */,
C38EF24C255B6D67007E1867 /* NSAttributedString+OWS.h in Headers */,
FDA8EB0A280E9103002B68E5 /* AppSetup.h in Headers */,
C38EF32B255B6DBF007E1867 /* OWSFormat.h in Headers */,
C33FDC2C255A581F00E217F9 /* OWSFailedAttachmentDownloadsJob.h in Headers */,
C38EF22A255B6D5D007E1867 /* AttachmentSharing.h in Headers */,
@ -3826,7 +3886,6 @@
C38EF404255B6DF7007E1867 /* ContactTableViewCell.h in Headers */,
C33FDDB2255A582000E217F9 /* NSArray+OWS.h in Headers */,
C38EF2D7255B6DAF007E1867 /* OWSProfileManager.h in Headers */,
C38EF290255B6D86007E1867 /* AppSetup.h in Headers */,
C38EF367255B6DCC007E1867 /* OWSTableViewController.h in Headers */,
C38EF246255B6D67007E1867 /* UIFont+OWS.h in Headers */,
C33FD9AF255A548A00E217F9 /* SignalUtilitiesKit.h in Headers */,
@ -3889,7 +3948,6 @@
C3A3A122256E1A97004D228D /* OWSDisappearingMessagesFinder.h in Headers */,
C3A3A12B256E1AD5004D228D /* TSDatabaseSecondaryIndexes.h in Headers */,
C32C5FC4256E0209003C73A2 /* OWSBackgroundTask.h in Headers */,
C32C5C24256DCB30003C73A2 /* NotificationsProtocol.h in Headers */,
C32C59C0256DB41F003C73A2 /* TSThread.h in Headers */,
C32C5AB3256DBE8F003C73A2 /* TSInteraction.h in Headers */,
C32C5DA5256DD6E5003C73A2 /* OWSOutgoingReceiptManager.h in Headers */,
@ -4309,7 +4367,6 @@
4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */,
4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */,
34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */,
A5509ECA1A69AB8B00ABA4BC /* Main.storyboard in Resources */,
C3CA3AA2255CDADA00F4C6D4 /* english.txt in Resources */,
B6F509971AA53F760068F56A /* Localizable.strings in Resources */,
C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */,
@ -4613,6 +4670,7 @@
C33FDD49255A582000E217F9 /* ParamParser.swift in Sources */,
C38EF3C5255B6DE7007E1867 /* OWSViewController+ImageEditor.swift in Sources */,
C38EF39B255B6DDA007E1867 /* ThreadViewModel.swift in Sources */,
FDA8EB09280E90FB002B68E5 /* AppSetup.m in Sources */,
C38EF2A5255B6D93007E1867 /* Identicon+ObjC.swift in Sources */,
C38EF273255B6D7A007E1867 /* OWSDatabaseMigrationRunner.m in Sources */,
C38EF31A255B6DBF007E1867 /* OWSAnyTouchGestureRecognizer.m in Sources */,
@ -4695,12 +4753,12 @@
C38EF3BD255B6DE7007E1867 /* ImageEditorTransform.swift in Sources */,
C38EF24F255B6D67007E1867 /* UIColor+OWS.m in Sources */,
C33FDC9A255A582000E217F9 /* ByteParser.m in Sources */,
C38EF293255B6D86007E1867 /* AppSetup.m in Sources */,
C33FDDC0255A582000E217F9 /* SignalAccount.m in Sources */,
C33FDC58255A582000E217F9 /* ReverseDispatchQueue.swift in Sources */,
C38EF3F4255B6DF7007E1867 /* ContactCellView.m in Sources */,
C33FDC78255A582000E217F9 /* TSConstants.m in Sources */,
C38EF324255B6DBF007E1867 /* Bench.swift in Sources */,
FDCDB8DE2810F73B00352A0C /* Differentiable+Utilities.swift in Sources */,
C38EF292255B6D86007E1867 /* VersionMigrations.m in Sources */,
C38EF3F2255B6DF7007E1867 /* DisappearingTimerConfigurationView.swift in Sources */,
C38EF3F9255B6DF7007E1867 /* OWSLayerView.swift in Sources */,
@ -4776,6 +4834,7 @@
C3BBE0A82554D4DE0050F1E3 /* JSON.swift in Sources */,
FD17D7C127F5200100122BE0 /* TypedTableDefinition.swift in Sources */,
FD17D7EA27F6A1C600122BE0 /* SUKLegacyModels.swift in Sources */,
FDA8EB10280F8238002B68E5 /* Codable+Utilities.swift in Sources */,
C352A36D2557858E00338F3E /* NSTimer+Proxying.m in Sources */,
FD09797B27FBB25900936362 /* Updatable.swift in Sources */,
C32C5A2D256DB849003C73A2 /* LKGroupUtilities.m in Sources */,
@ -4783,6 +4842,7 @@
B8856E09256F1676001CE70E /* UIDevice+featureSupport.swift in Sources */,
B8856DEF256F161F001CE70E /* NSString+SSK.m in Sources */,
FD09796727F6B0B600936362 /* Sodium+Conversion.swift in Sources */,
FDF0B74D280664E9004C14C5 /* PersistableRecord+Utilities.swift in Sources */,
FD09797927FAB7E800936362 /* ImageFormat.swift in Sources */,
C32C5DC9256DD935003C73A2 /* ProxiedContentDownloader.swift in Sources */,
C3D9E4C02567767F0040E4F3 /* DataSource.m in Sources */,
@ -4859,7 +4919,9 @@
C3A3A156256E1B91004D228D /* ProtoUtils.m in Sources */,
FD09799927FFC1A300936362 /* Attachment.swift in Sources */,
C3471ECB2555356A00297E91 /* MessageSender+Encryption.swift in Sources */,
C352A32F2557549C00338F3E /* NotifyPNServerJob.swift in Sources */,
FDE77F69280F9EDA002CFC5D /* JobRunnerError.swift in Sources */,
FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */,
C352A32F2557549C00338F3E /* NotifyPushServerJob.swift in Sources */,
7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */,
C300A5F22554B09800555489 /* MessageSender.swift in Sources */,
C3C2A74D2553A39700C340D1 /* VisibleMessage.swift in Sources */,
@ -4876,21 +4938,26 @@
C3C2A7562553A3AB00C340D1 /* VisibleMessage+Quote.swift in Sources */,
C3227FF6260AAD66006EA627 /* OpenGroupMessageV2.swift in Sources */,
C32C5C89256DD0D2003C73A2 /* Storage+Jobs.swift in Sources */,
FDF0B75C2807F41D004C14C5 /* MessageSender+Convenience.swift in Sources */,
FD09799727FFA84A00936362 /* RecipientState.swift in Sources */,
C300A5FC2554B0A000555489 /* MessageReceiver.swift in Sources */,
7B1581E2271E743B00848B49 /* OWSSounds.swift in Sources */,
C32C5A76256DBBCF003C73A2 /* SignalAttachment.swift in Sources */,
FDA8EB00280E8D58002B68E5 /* FailedAttachmentDownloadsJob.swift in Sources */,
FD09798927FD1C5A00936362 /* OpenGroup.swift in Sources */,
C32C5CA4256DD1DC003C73A2 /* TSAccountManager.m in Sources */,
C352A3892557876500338F3E /* JobQueue.swift in Sources */,
C3BBE0B52554F0E10050F1E3 /* ProofOfWork.swift in Sources */,
FDF0B740280402C4004C14C5 /* Job.swift in Sources */,
C32C59C1256DB41F003C73A2 /* TSGroupThread.m in Sources */,
C3A3A08F256E1728004D228D /* FullTextSearchFinder.swift in Sources */,
FDF0B7472804F0CE004C14C5 /* DisappearingMessagesJob.swift in Sources */,
B8856D1A256F114D001CE70E /* ProximityMonitoringManager.swift in Sources */,
C3D9E52725677DF20040E4F3 /* OWSThumbnailService.swift in Sources */,
C32C5E75256DE020003C73A2 /* YapDatabaseTransaction+OWS.m in Sources */,
FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */,
FD09797527FAB64300936362 /* ProfileManager.swift in Sources */,
FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */,
C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */,
C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */,
B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */,
@ -4903,20 +4970,24 @@
C3A3A107256E1A5C004D228D /* OWSDisappearingMessagesFinder.m in Sources */,
FD09798727FD1B7800936362 /* GroupMember.swift in Sources */,
FD09799127FD499200936362 /* BoxKeyPair+Utilities.swift in Sources */,
FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */,
C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */,
B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */,
FD17D79927F40AB800122BE0 /* _002_YDBToGRDBMigration.swift in Sources */,
FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */,
C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */,
FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */,
C32C5DC0256DD743003C73A2 /* Poller.swift in Sources */,
C3C2A7682553A3D900C340D1 /* VisibleMessage+Contact.swift in Sources */,
C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */,
C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */,
FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */,
B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */,
C32C5D24256DD4C0003C73A2 /* MentionsManager.swift in Sources */,
C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */,
C3C2A7852553AAF300C340D1 /* SessionProtos.pb.swift in Sources */,
B8566C63256F55930045A0B9 /* OWSLinkPreview+Conversion.swift in Sources */,
C32C5B3F256DC1DF003C73A2 /* TSQuotedMessage+Conversion.swift in Sources */,
FDF0B74F28079E5E004C14C5 /* SendReadReceiptsJob.swift in Sources */,
FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */,
B8EB20EE2640F28000773E52 /* VisibleMessage+OpenGroupInvitation.swift in Sources */,
FD17D7A227F40F0500122BE0 /* _001_InitialSetupMigration.swift in Sources */,
C3C2A7712553A41E00C340D1 /* ControlMessage.swift in Sources */,
@ -4924,6 +4995,7 @@
FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */,
C32C5CF0256DD3E4003C73A2 /* Storage+Shared.swift in Sources */,
C300A5BD2554B00D00555489 /* ReadReceipt.swift in Sources */,
FDF0B7582807F368004C14C5 /* MessageReceiverError.swift in Sources */,
C32C5AB5256DBE8F003C73A2 /* TSOutgoingMessage+Conversion.swift in Sources */,
C32C5E5B256DDF45003C73A2 /* OWSStorage.m in Sources */,
C32C5E15256DDC78003C73A2 /* SSKPreferences.swift in Sources */,
@ -4933,6 +5005,7 @@
B8856CEE256F1054001CE70E /* OWSAudioPlayer.m in Sources */,
FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */,
FD09798D27FD1D8900936362 /* DisappearingMessageConfiguration.swift in Sources */,
FDF0B75A2807F3A3004C14C5 /* MessageSenderError.swift in Sources */,
C32C5EDC256DF501003C73A2 /* YapDatabaseConnection+OWS.m in Sources */,
C3BBE0762554CDA60050F1E3 /* Configuration.swift in Sources */,
C35D76DB26606304009AA5FB /* ThreadUpdateBatcher.swift in Sources */,
@ -4941,7 +5014,6 @@
C3ECBF7B257056B700EA7FCE /* Threading.swift in Sources */,
C32C5AAB256DBE8F003C73A2 /* TSIncomingMessage+Conversion.swift in Sources */,
C32C5A88256DBCF9003C73A2 /* MessageReceiver+Handling.swift in Sources */,
C32C5A02256DB658003C73A2 /* MessageSender+Convenience.swift in Sources */,
B8566C6C256F60F50045A0B9 /* OWSUserProfile.m in Sources */,
B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */,
C32C599E256DB02B003C73A2 /* TypingIndicators.swift in Sources */,
@ -4956,7 +5028,9 @@
B8856E94256F1C37001CE70E /* OWSSounds.m in Sources */,
C32C5BCC256DC830003C73A2 /* Storage+ClosedGroups.swift in Sources */,
C3A3A0EC256E1949004D228D /* OWSRecipientIdentity.m in Sources */,
FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */,
B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */,
FDF0B7442804EF1B004C14C5 /* JobRunner.swift in Sources */,
C32C5AB2256DBE8F003C73A2 /* TSMessage.m in Sources */,
FD09796E27FA6D0000936362 /* Contact.swift in Sources */,
C3A3A0FE256E1A3C004D228D /* TSDatabaseSecondaryIndexes.m in Sources */,
@ -5001,8 +5075,9 @@
C32C5AF8256DC051003C73A2 /* OWSDisappearingMessagesConfiguration.m in Sources */,
C32C5EBA256DE130003C73A2 /* OWSQuotedReplyModel.m in Sources */,
C32C5B62256DC333003C73A2 /* OWSDisappearingConfigurationUpdateInfoMessage.m in Sources */,
C352A2F525574B4700338F3E /* Job.swift in Sources */,
C352A2F525574B4700338F3E /* LegacyJob.swift in Sources */,
C32C59C4256DB41F003C73A2 /* TSContactThread.m in Sources */,
FDF0B75E280AAF35004C14C5 /* Preferences.swift in Sources */,
C32C5AB0256DBE8F003C73A2 /* TSOutgoingMessage.m in Sources */,
B82A0C3826B9098200C1BCE3 /* MessageInvalidator.swift in Sources */,
);
@ -5035,6 +5110,7 @@
B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */,
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */,
FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */,
451166C01FD86B98000739BA /* AccountManager.swift in Sources */,
C374EEF425DB31D40073A857 /* VoiceMessageRecordingView.swift in Sources */,
B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */,
@ -5056,7 +5132,6 @@
C3D0972B2510499C00F6E3E4 /* BackgroundPoller.swift in Sources */,
C3548F0624456447009433A8 /* PNModeVC.swift in Sources */,
B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */,
D221A09A169C9E5E00537ABF /* main.m in Sources */,
B835247925C38D880089A44F /* MessageCell.swift in Sources */,
B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */,
34E3E5681EC4B19400495BAC /* AudioProgressView.swift in Sources */,
@ -5134,7 +5209,6 @@
4C21D5D6223A9DC500EF8A77 /* UIAlerts+iOS9.m in Sources */,
B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */,
B8269D3D25C7B34D00488AB4 /* InputTextView.swift in Sources */,
76EB054018170B33006006FC /* AppDelegate.m in Sources */,
340FC8B6204DAC8D007AEB0F /* OWSQRCodeScanningViewController.m in Sources */,
C33100082558FF6D00070591 /* NewConversationButtonSet.swift in Sources */,
7BA7F4BB279F9F5800B3A466 /* EmptySearchResultCell.swift in Sources */,

View File

@ -1118,7 +1118,7 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate {
extension ConversationVC {
fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: UInt64) -> Promise<Void> {
fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: Double) -> Promise<Void> {
guard let contactThread: TSContactThread = thread as? TSContactThread else { return Promise.value(()) }
// If the contact doesn't exist then we should create it so we can store the 'isApproved' state

View File

@ -11,7 +11,7 @@ final class LinkPreviewView : UIView {
private lazy var sentLinkPreviewTextColor: UIColor = {
let isOutgoing = (viewItem?.interaction.interactionType() == .outgoingMessage)
switch (isOutgoing, AppModeManager.shared.currentAppMode) {
case (true, .dark), (false, .light): return .black
case (false, .light): return .black
case (true, .light): return Colors.grey
default: return .white
}

View File

@ -815,9 +815,7 @@ CGFloat kIconViewLength = 24;
if (gThread.isClosedGroup) {
NSString *groupPublicKey = [LKGroupUtilities getDecodedGroupID:gThread.groupModel.groupId];
[LKStorage writeSyncWithBlock:^(YapDatabaseReadWriteTransaction *_Nonnull transaction) {
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey using:transaction] retainUntilComplete];
}];
[[SNMessageSender leaveClosedGroupWithPublicKey:groupPublicKey] retainUntilComplete];
}
[self.navigationController popViewControllerAnimated:YES];

View File

@ -25,7 +25,7 @@ final class BlockedModal: Modal {
override func populateContentView() {
// Name
let name = Profile.displayName(for: publicKey)
let name = Profile.displayName(id: publicKey)
// Title
let titleLabel = UILabel()
titleLabel.textColor = Colors.text
@ -80,7 +80,7 @@ final class BlockedModal: Modal {
.update(db)
},
completion: { db, _ in
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
)

View File

@ -88,7 +88,7 @@ final class ConversationTitleView : UIView {
let sessionID = (thread as! TSContactThread).contactSessionID()
let middleTruncatedHexKey: String = "\(sessionID.prefix(4))...\(sessionID.suffix(4))"
return Profile.displayName(for: sessionID, customFallback: middleTruncatedHexKey)
return Profile.displayName(id: sessionID, customFallback: middleTruncatedHexKey)
}
}

View File

@ -1,5 +1,11 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
final class JoinOpenGroupModal : Modal {
import UIKit
import GRDB
import SessionMessagingKit
import SessionUtilitiesKit
final class JoinOpenGroupModal: Modal {
private let name: String
private let url: String
@ -63,24 +69,28 @@ final class JoinOpenGroupModal : Modal {
// MARK: Interaction
@objc private func joinOpenGroup() {
guard let presentingViewController: UIViewController = self.presentingViewController else { return }
guard let (room, server, publicKey) = OpenGroupManagerV2.parseV2OpenGroup(from: url) else {
let alert = UIAlertController(title: "Couldn't Join", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
return presentingViewController!.present(alert, animated: true, completion: nil)
return presentingViewController.present(alert, animated: true, completion: nil)
}
presentingViewController!.dismiss(animated: true, completion: nil)
Storage.shared.write { [presentingViewController = self.presentingViewController!] transaction in
OpenGroupManagerV2.shared.add(room: room, server: server, publicKey: publicKey, using: transaction)
.done(on: DispatchQueue.main) { _ in
GRDBStorage.shared.write { db in
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
}
}
.catch(on: DispatchQueue.main) { error in
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
presentingViewController.present(alert, animated: true, completion: nil)
presentingViewController.dismiss(animated: true, completion: nil)
GRDBStorage.shared.write { db in
OpenGroupManagerV2.shared
.add(db, room: room, server: server, publicKey: publicKey)
}
.done(on: DispatchQueue.main) { _ in
GRDBStorage.shared.write { db in
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() // FIXME: It's probably cleaner to do this inside addOpenGroup(...)
}
}
.catch(on: DispatchQueue.main) { error in
let alert = UIAlertController(title: "Couldn't Join", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil))
presentingViewController.present(alert, animated: true, completion: nil)
}
}
}

View File

@ -30,7 +30,7 @@ final class UserDetailsSheet: Sheet {
profilePictureView.update()
// Display name label
let displayNameLabel = UILabel()
let displayName = Profile.displayName(for: sessionID)
let displayName = Profile.displayName(id: sessionID)
displayNameLabel.text = displayName
displayNameLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
displayNameLabel.textColor = Colors.text

View File

@ -2,44 +2,20 @@
import UIKit
import GRDB
import DifferenceKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
// See https://github.com/yapstudios/YapDatabase/wiki/LongLivedReadTransactions and
// https://github.com/yapstudios/YapDatabase/wiki/YapDatabaseModifiedNotification for
// more information on database handling.
final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
private var threads: YapDatabaseViewMappings!
private var threadViewModelCache: [String:ThreadViewModel] = [:] // Thread ID to ThreadViewModel
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
private let viewModel: HomeViewModel = HomeViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialData: Bool = false
// MARK: - UI
private var tableViewTopConstraint: NSLayoutConstraint!
private var unreadMessageRequestCount: UInt {
var count: UInt = 0
dbConnection.read { transaction in
let ext = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction
ext.enumerateRows(inGroup: TSMessageRequestGroup) { _, _, object, _, _, _ in
if ((object as? TSThread)?.unreadMessageCount(transaction: transaction) ?? 0) > 0 {
count += 1
}
}
}
return count
}
private var threadCount: UInt {
threads.numberOfItems(inGroup: TSInboxGroup)
}
private lazy var dbConnection: YapDatabaseConnection = {
let result = OWSPrimaryStorage.shared().newDatabaseConnection()
result.objectCacheLimit = 500
return result
}()
private var isReloading = false
// MARK: UI Components
private lazy var seedReminderView: SeedReminderView = {
let result = SeedReminderView(hasContinueButton: true)
let title = "You're almost finished! 80%"
@ -49,6 +25,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
result.subtitle = NSLocalizedString("view_seed_reminder_subtitle_1", comment: "")
result.setProgress(0.8, animated: false)
result.delegate = self
return result
}()
@ -56,11 +33,23 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
let result = UITableView()
result.backgroundColor = .clear
result.separatorStyle = .none
result.contentInset = UIEdgeInsets(
top: 0,
left: 0,
bottom: (
Values.newConversationButtonBottomOffset +
NewConversationButtonSet.expandedButtonSize +
Values.largeSpacing +
NewConversationButtonSet.collapsedButtonSize
),
right: 0
)
result.showsVerticalScrollIndicator = false
result.register(MessageRequestsCell.self, forCellReuseIdentifier: MessageRequestsCell.reuseIdentifier)
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
let bottomInset = Values.newConversationButtonBottomOffset + NewConversationButtonSet.expandedButtonSize + Values.largeSpacing + NewConversationButtonSet.collapsedButtonSize
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: bottomInset, right: 0)
result.showsVerticalScrollIndicator = false
result.dataSource = self
result.delegate = self
return result
}()
@ -75,6 +64,7 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
let gradient = Gradients.homeVCFade
result.setGradient(gradient)
result.isUserInteractionEnabled = false
return result
}()
@ -95,20 +85,20 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
result.spacing = Values.mediumSpacing
result.alignment = .center
result.isHidden = true
return result
}()
// MARK: Lifecycle
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// Note: This is a hack to ensure `isRTL` is initially gets run on the main thread so the value is cached (it gets
// called on background threads and if it hasn't cached the value then it can cause odd performance issues since
// it accesses UIKit)
// Note: This is a hack to ensure `isRTL` is initially gets run on the main thread so the value
// is cached (it gets called on background threads and if it hasn't cached the value then it can
// cause odd performance issues since it accesses UIKit)
_ = CurrentAppContext().isRTL
// Threads (part 1)
dbConnection.beginLongLivedReadTransaction() // Freeze the connection for use on the main thread (this gives us a stable data source that doesn't change until we tell it to)
// Preparation
SignalApp.shared().homeViewController = self
// Gradient & nav bar
@ -126,9 +116,8 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
seedReminderView.pin(.top, to: .top, of: view)
seedReminderView.pin(.trailing, to: .trailing, of: view)
}
// Table view
tableView.dataSource = self
tableView.delegate = self
view.addSubview(tableView)
tableView.pin(.leading, to: .leading, of: view)
if !hasViewedSeed {
@ -144,255 +133,119 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
fadeView.pin(.top, to: .top, of: view, withInset: topInset)
fadeView.pin(.trailing, to: .trailing, of: view)
fadeView.pin(.bottom, to: .bottom, of: view)
// Empty state view
view.addSubview(emptyStateView)
emptyStateView.center(.horizontal, in: view)
let verticalCenteringConstraint = emptyStateView.center(.vertical, in: view)
verticalCenteringConstraint.constant = -16 // Makes things appear centered visually
// New conversation button set
view.addSubview(newConversationButtonSet)
newConversationButtonSet.center(.horizontal, in: view)
newConversationButtonSet.pin(.bottom, to: .bottom, of: view, withInset: -Values.newConversationButtonBottomOffset) // Negative due to how the constraint is set up
// Notifications
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(self, selector: #selector(handleYapDatabaseModifiedNotification(_:)), name: .YapDatabaseModified, object: OWSPrimaryStorage.shared().dbNotificationObject)
notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: Notification.Name.otherUsersProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: Notification.Name.localProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleProfileDidChangeNotification(_:)), name: .otherUsersProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleLocalProfileDidChangeNotification(_:)), name: .localProfileDidChange, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleSeedViewedNotification(_:)), name: .seedViewed, object: nil)
notificationCenter.addObserver(self, selector: #selector(handleBlockedContactsUpdatedNotification(_:)), name: .blockedContactsUpdated, object: nil)
notificationCenter.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: .OWSApplicationDidBecomeActive, object: nil)
// Threads (part 2)
threads = YapDatabaseViewMappings(groups: [ TSMessageRequestGroup, TSInboxGroup ], view: TSThreadDatabaseViewExtensionName) // The extension should be registered at this point
threads.setIsReversed(true, forGroup: TSInboxGroup)
dbConnection.read { transaction in
self.threads.update(with: transaction) // Perform the initial update
}
// Start polling if needed (i.e. if the user just created or restored their Session ID)
if Identity.userExists() {
let appDelegate = UIApplication.shared.delegate as! AppDelegate
appDelegate.startPollerIfNeeded()
appDelegate.startClosedGroupPoller()
appDelegate.startOpenGroupPollersIfNeeded()
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
appDelegate.startPollersIfNeeded()
// Do this only if we created a new Session ID, or if we already received the initial configuration message
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
appDelegate.syncConfigurationIfNeeded()
}
}
// Re-populate snode pool if needed
SnodeAPI.getSnodePool().retainUntilComplete()
// Onion request path countries cache
DispatchQueue.global(qos: .utility).sync {
let _ = IP2Country.shared.populateCacheIfNeeded()
}
// Get default open group rooms if needed
OpenGroupAPIV2.getDefaultRoomsIfNeeded()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
reload()
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
startObservingChanges()
}
@objc private func applicationDidBecomeActive(_ notification: Notification) {
reload()
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
startObservingChanges()
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return 2
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0:
if unreadMessageRequestCount > 0 && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] {
return 1
}
return 0
case 1: return Int(threadCount)
default: return 0
}
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch indexPath.section {
case 0:
let cell = tableView.dequeueReusableCell(withIdentifier: MessageRequestsCell.reuseIdentifier) as! MessageRequestsCell
cell.update(with: Int(unreadMessageRequestCount))
return cell
default:
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.threadViewModel = threadViewModel(at: indexPath.row)
return cell
}
}
// MARK: Updating
// MARK: - Updating
private func reload() {
AssertIsOnMainThread()
guard !isReloading else { return }
isReloading = true
dbConnection.beginLongLivedReadTransaction() // Jump to the latest commit
dbConnection.read { transaction in
self.threads.update(with: transaction)
}
threadViewModelCache.removeAll()
tableView.reloadData()
emptyStateView.isHidden = (threadCount != 0)
isReloading = false
private func startObservingChanges() {
// Start observing for data changes
dataChangeObservable = GRDBStorage.shared.start(
viewModel.observableViewData,
onError: { error in
print("Update error!!!!")
},
onChange: { [weak self] viewData in
// The defaul scheduler emits changes on the main thread
self?.handleUpdates(viewData)
}
)
}
@objc private func handleYapDatabaseModifiedNotification(_ yapDatabase: YapDatabase) {
// NOTE: This code is very finicky and crashes easily. Modify with care.
AssertIsOnMainThread()
// If we don't capture `threads` here, a race condition can occur where the
// `thread.snapshotOfLastUpdate != firstSnapshot - 1` check below evaluates to
// `false`, but `threads` then changes between that check and the
// `ext.getSectionChanges(&sectionChanges, rowChanges: &rowChanges, for: notifications, with: threads)`
// line. This causes `tableView.endUpdates()` to crash with an `NSInternalInconsistencyException`.
let threads = threads!
// Create a stable state for the connection and jump to the latest commit
let notifications = dbConnection.beginLongLivedReadTransaction()
guard !notifications.isEmpty else { return }
let ext = dbConnection.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewConnection
let hasChanges = (
ext.hasChanges(forGroup: TSMessageRequestGroup, in: notifications) ||
ext.hasChanges(forGroup: TSInboxGroup, in: notifications)
private func handleUpdates(_ updatedViewData: [ArraySection<HomeViewModel.Section, HomeViewModel.Item>]) {
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
hasLoadedInitialData = true
UIView.performWithoutAnimation { handleUpdates(updatedViewData) }
return
}
// Show the empty state if there is no data
emptyStateView.isHidden = (
!updatedViewData.isEmpty &&
updatedViewData.contains(where: { !$0.elements.isEmpty })
)
guard hasChanges else { return }
if let firstChangeSet = notifications[0].userInfo {
let firstSnapshot = firstChangeSet[YapDatabaseSnapshotKey] as! UInt64
// The 'getSectionChanges' code below will crash if we try to process multiple commits at once
// so just force a full reload
if threads.snapshotOfLastUpdate != firstSnapshot - 1 {
// Check if we inserted a new message request (if so then unhide the message request banner)
if
let extensions: [String: Any] = firstChangeSet[YapDatabaseExtensionsKey] as? [String: Any],
let viewExtensions: [String: Any] = extensions[TSThreadDatabaseViewExtensionName] as? [String: Any]
{
// Note: We do a 'flatMap' here rather than explicitly grab the desired key because
// the key we need is 'changeset_key_changes' in 'YapDatabaseViewPrivate.h' so could
// change due to an update and silently break this - this approach is a bit safer
let allChanges: [Any] = Array(viewExtensions.values).compactMap { $0 as? [Any] }.flatMap { $0 }
let messageRequestInserts = allChanges
.compactMap { $0 as? YapDatabaseViewRowChange }
.filter { $0.finalGroup == TSMessageRequestGroup && $0.type == .insert }
if !messageRequestInserts.isEmpty && CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] {
CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = false
}
}
// If there are no unread message requests then hide the message request banner
if unreadMessageRequestCount == 0 {
CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true
}
return reload()
}
// Reload the table content (animate changes after the first load)
tableView.reload(
using: StagedChangeset(source: viewModel.viewData, target: updatedViewData),
with: .automatic,
interrupt: {
print("Interrupt change check: \($0.changeCount)")
return $0.changeCount > 100
} // Prevent too many changes from causing performance issues
) { [weak self] updatedData in
self?.viewModel.updateData(updatedData)
}
var sectionChanges = NSArray()
var rowChanges = NSArray()
ext.getSectionChanges(&sectionChanges, rowChanges: &rowChanges, for: notifications, with: threads)
// Separate out the changes for new message requests and the inbox (so we can avoid updating for
// new messages within an existing message request)
let messageRequestChanges = rowChanges
.compactMap { $0 as? YapDatabaseViewRowChange }
.filter { $0.originalGroup == TSMessageRequestGroup || $0.finalGroup == TSMessageRequestGroup }
let inboxRowChanges = rowChanges
.compactMap { $0 as? YapDatabaseViewRowChange }
.filter { $0.originalGroup == TSInboxGroup || $0.finalGroup == TSInboxGroup }
guard sectionChanges.count > 0 || inboxRowChanges.count > 0 || messageRequestChanges.count > 0 else { return }
tableView.beginUpdates()
// If we need to unhide the message request row and then re-insert it
if !messageRequestChanges.isEmpty {
// If there are no unread message requests then hide the message request banner
if unreadMessageRequestCount == 0 && tableView.numberOfRows(inSection: 0) == 1 {
CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true
tableView.deleteRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
}
else {
if tableView.numberOfRows(inSection: 0) == 1 && Int(unreadMessageRequestCount) <= 0 {
tableView.deleteRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
}
else if tableView.numberOfRows(inSection: 0) == 0 && Int(unreadMessageRequestCount) > 0 && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] {
tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .automatic)
}
}
}
inboxRowChanges.forEach { rowChange in
let key = rowChange.collectionKey.key
threadViewModelCache[key] = nil
switch rowChange.type {
case .delete:
tableView.deleteRows(at: [ rowChange.indexPath! ], with: .automatic)
case .insert:
tableView.insertRows(at: [ rowChange.newIndexPath! ], with: .automatic)
case .update:
tableView.reloadRows(at: [ rowChange.indexPath! ], with: .automatic)
case .move:
// Note: We need to handle the move from the message requests section to the inbox (since
// we are only showing a single row for message requests we need to custom handle this as
// an insert as the change won't be defined correctly)
if rowChange.originalGroup == TSMessageRequestGroup && rowChange.finalGroup == TSInboxGroup {
tableView.insertRows(at: [ rowChange.newIndexPath! ], with: .automatic)
}
else if rowChange.originalGroup == TSInboxGroup && rowChange.finalGroup == TSMessageRequestGroup {
tableView.deleteRows(at: [ rowChange.indexPath! ], with: .automatic)
}
default: break
}
}
tableView.endUpdates()
// HACK: Moves can have conflicts with the other 3 types of change.
// Just batch perform all the moves separately to prevent crashing.
// Since all the changes are from the original state to the final state,
// it will still be correct if we pick the moves out.
tableView.beginUpdates()
rowChanges.forEach { rowChange in
let rowChange = rowChange as! YapDatabaseViewRowChange
let key = rowChange.collectionKey.key
threadViewModelCache[key] = nil
switch rowChange.type {
case .move:
// Since we are custom handling this specific movement in the above 'updates' call we need
// to avoid trying to handle it here
if rowChange.originalGroup == TSMessageRequestGroup || rowChange.finalGroup == TSMessageRequestGroup {
return
}
tableView.moveRow(at: rowChange.indexPath!, to: rowChange.newIndexPath!)
default: break
}
}
tableView.endUpdates()
emptyStateView.isHidden = (threadCount != 0)
}
@objc private func handleProfileDidChangeNotification(_ notification: Notification) {
@ -427,13 +280,16 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
profilePictureView.update()
profilePictureView.set(.width, to: profilePictureSize)
profilePictureView.set(.height, to: profilePictureSize)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
// Path status indicator
let pathStatusView = PathStatusView()
pathStatusView.accessibilityLabel = "Current onion routing path indicator"
pathStatusView.set(.width, to: PathStatusView.size)
pathStatusView.set(.height, to: PathStatusView.size)
// Container view
let profilePictureViewContainer = UIView()
profilePictureViewContainer.accessibilityLabel = "Settings button"
@ -458,11 +314,36 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
@objc override internal func handleAppModeChangedNotification(_ notification: Notification) {
super.handleAppModeChangedNotification(notification)
let gradient = Gradients.homeVCFade
fadeView.setGradient(gradient) // Re-do the gradient
tableView.reloadData()
}
// MARK: - UITableViewDataSource
func numberOfSections(in tableView: UITableView) -> Int {
return viewModel.viewData.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.viewData[section].elements.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch viewModel.viewData[indexPath.section].model {
case .messageRequests:
let cell = tableView.dequeueReusableCell(withIdentifier: MessageRequestsCell.reuseIdentifier) as! MessageRequestsCell
cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].unreadCount)
return cell
case .threads:
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
cell.update(with: viewModel.viewData[indexPath.section].elements[indexPath.row].threadViewModel)
return cell
}
}
// MARK: - UITableViewDelegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
@ -485,10 +366,10 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
switch indexPath.section {
case 0:
switch viewModel.viewData[indexPath.section].model {
case .messageRequests:
let hide = UITableViewRowAction(style: .destructive, title: NSLocalizedString("TXT_HIDE_TITLE", comment: "")) { [weak self] _, _ in
CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = true
GRDBStorage.shared.write { db in db[.hasHiddenMessageRequests] = true }
// Animate the row removal
self?.tableView.beginUpdates()

View File

@ -0,0 +1,119 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
public class HomeViewModel {
public enum Section: Differentiable {
case messageRequests
case threads
}
public struct Item: Equatable, Differentiable {
public var differenceIdentifier: String {
return (threadViewModel?.thread.id ?? "\(unreadCount)")
}
let unreadCount: Int
let threadViewModel: ThreadViewModel?
}
/// This value is the current state of the view
public private(set) var viewData: [ArraySection<Section, Item>] = []
/// This is all the data the HomeVC needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
public lazy var observableViewData = ValueObservation.tracking { db -> [ArraySection<Section, Item>] in
// If message requests are hidden then don't bother fetching the unread count
let unreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ?
0 :
try SessionThread
.messageRequestThreads(db)
.joining(
required: SessionThread.interactions
.filter(Interaction.Columns.wasRead == false)
)
.fetchCount(db)
)
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let threadViewModels = try SessionThread
.fetchAll(db)
.compactMap { thread -> ThreadViewModel? in
let lastInteraction: Interaction? = try thread
.interactions
.order(Interaction.Columns.id.desc)
.fetchOne(db)
// Only show the 'Note to Self' thread if it has interactions
guard !thread.isNoteToSelf(db) || lastInteraction != nil else { return nil }
let unreadMessageCount: Int = try thread
.interactions
.filter(Interaction.Columns.wasRead == false)
.fetchCount(db)
let quoteAlias: TableAlias = TableAlias()
let unreadMentionCount: Int = try thread
.interactions
.filter(Interaction.Columns.wasRead == false)
.joining(
optional: Interaction.quote
.aliased(quoteAlias)// TODO: Test that this works
)
.filter(
Interaction.Columns.body.like("%@\(userPublicKey)") ||
quoteAlias[Quote.Columns.authorId] == userPublicKey
)
.fetchCount(db)
return ThreadViewModel(
thread: thread,
name: thread.name(db),
unreadCount: UInt(unreadMessageCount),
unreadMentionCount: UInt(unreadMentionCount),
lastInteraction: lastInteraction,
lastInteractionDate: (
lastInteraction.map { Date(timeIntervalSince1970: Double($0.timestampMs / 1000)) } ??
Date(timeIntervalSince1970: thread.creationDateTimestamp)
),
lastInteractionText: lastInteraction?.previewText(db),
lastInteractionState: try lastInteraction?.state(db)
)
}
return [
ArraySection(
model: .messageRequests,
elements: [
// If there are no unread message requests then hide the message request banner
(unreadMessageRequestCount == 0 ?
nil :
Item(
unreadCount: unreadMessageRequestCount,
threadViewModel: nil
)
)
].compactMap { $0 }
),
ArraySection(
model: .threads,
elements: threadViewModels
.sorted(by: { lhs, rhs in lhs.lastInteractionDate > rhs.lastInteractionDate })
.map {
Item(
unreadCount: Int($0.unreadCount),
threadViewModel: $0
)
}
),
]
}
// MARK: - Functions
public func updateData(_ updatedData: [ArraySection<Section, Item>]) {
self.viewData = updatedData
}
}

View File

@ -1,16 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import <UIKit/UIKit.h>
extern NSString *const AppDelegateStoryboardMain;
@interface AppDelegate : UIResponder <UIApplicationDelegate>
- (void)startPollerIfNeeded;
- (void)stopPoller;
- (void)startOpenGroupPollersIfNeeded;
- (void)stopOpenGroupPollers;
@end

View File

@ -1,787 +0,0 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import "AppDelegate.h"
#import "MainAppContext.h"
#import "OWSScreenLockUI.h"
#import "Session-Swift.h"
#import "SignalApp.h"
#import <PromiseKit/AnyPromise.h>
#import <SignalCoreKit/iOSVersions.h>
#import <SignalUtilitiesKit/AppSetup.h>
#import <SessionMessagingKit/Environment.h>
#import <SignalUtilitiesKit/OWSNavigationController.h>
#import <SessionMessagingKit/OWSPreferences.h>
#import <SignalUtilitiesKit/OWSProfileManager.h>
#import <SignalUtilitiesKit/VersionMigrations.h>
#import <SessionMessagingKit/AppReadiness.h>
#import <SessionUtilitiesKit/NSUserDefaults+OWS.h>
#import <SessionMessagingKit/OWSDisappearingMessagesJob.h>
#import <SignalUtilitiesKit/OWSFailedAttachmentDownloadsJob.h>
#import <SignalUtilitiesKit/OWSFailedMessagesJob.h>
#import <SessionUtilitiesKit/OWSMath.h>
#import <SessionMessagingKit/OWSReadReceiptManager.h>
#import <SessionMessagingKit/SSKEnvironment.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
#import <SessionMessagingKit/TSAccountManager.h>
#import <SessionMessagingKit/TSDatabaseView.h>
#import <YapDatabase/YapDatabaseCryptoUtils.h>
#import <sys/utsname.h>
@import Intents;
NSString *const AppDelegateStoryboardMain = @"Main";
static NSString *const kInitialViewControllerIdentifier = @"UserInitialViewController";
static NSString *const kURLSchemeSGNLKey = @"sgnl";
static NSString *const kURLHostVerifyPrefix = @"verify";
static NSTimeInterval launchStartedAt;
@interface AppDelegate () <UNUserNotificationCenterDelegate, LKAppModeManagerDelegate>
@property (nonatomic) BOOL hasInitialRootViewController;
@property (nonatomic) BOOL areVersionMigrationsComplete;
@property (nonatomic) BOOL didAppLaunchFail;
@property (nonatomic) LKPoller *poller;
@end
#pragma mark -
@implementation AppDelegate
@synthesize window = _window;
#pragma mark - Dependencies
- (OWSReadReceiptManager *)readReceiptManager
{
return [OWSReadReceiptManager sharedManager];
}
- (OWSPrimaryStorage *)primaryStorage
{
OWSAssertDebug(SSKEnvironment.shared.primaryStorage);
return SSKEnvironment.shared.primaryStorage;
}
- (PushRegistrationManager *)pushRegistrationManager
{
OWSAssertDebug(AppEnvironment.shared.pushRegistrationManager);
return AppEnvironment.shared.pushRegistrationManager;
}
- (TSAccountManager *)tsAccountManager
{
OWSAssertDebug(SSKEnvironment.shared.tsAccountManager);
return SSKEnvironment.shared.tsAccountManager;
}
- (OWSDisappearingMessagesJob *)disappearingMessagesJob
{
OWSAssertDebug(SSKEnvironment.shared.disappearingMessagesJob);
return SSKEnvironment.shared.disappearingMessagesJob;
}
- (OWSWindowManager *)windowManager
{
return Environment.shared.windowManager;
}
- (OWSNotificationPresenter *)notificationPresenter
{
return AppEnvironment.shared.notificationPresenter;
}
- (OWSUserNotificationActionHandler *)userNotificationActionHandler
{
return AppEnvironment.shared.userNotificationActionHandler;
}
#pragma mark - Lifecycle
- (void)applicationDidEnterBackground:(UIApplication *)application
{
[DDLog flushLog];
[self stopPoller];
[self stopClosedGroupPoller];
[self stopOpenGroupPollers];
}
- (void)applicationDidReceiveMemoryWarning:(UIApplication *)application
{
OWSLogInfo(@"applicationDidReceiveMemoryWarning");
}
- (void)applicationWillTerminate:(UIApplication *)application
{
[DDLog flushLog];
[self stopPoller];
[self stopClosedGroupPoller];
[self stopOpenGroupPollers];
}
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// This should be the first thing we do
SetCurrentAppContext([MainAppContext new]);
launchStartedAt = CACurrentMediaTime();
[LKAppModeManager configureWithDelegate:self];
// OWSLinkPreview is now in SessionMessagingKit, so to still be able to deserialize them we
// need to tell NSKeyedUnarchiver about the changes.
[NSKeyedUnarchiver setClass:OWSLinkPreview.class forClassName:@"SessionServiceKit.OWSLinkPreview"];
[Cryptography seedRandom];
// XXX - careful when moving this. It must happen before we initialize OWSPrimaryStorage.
[self verifyDBKeysAvailableBeforeBackgroundLaunch];
[AppVersion sharedInstance];
// Prevent the device from sleeping during database view async registration
// (e.g. long database upgrades).
//
// This block will be cleared in storageIsReady.
[DeviceSleepManager.sharedInstance addBlockWithBlockObject:self];
[AppSetup
setupEnvironmentWithAppSpecificSingletonBlock:^{
// Create AppEnvironment
[AppEnvironment.shared setup];
[SignalApp.sharedApp setup];
}
migrationCompletion:^(BOOL successful, BOOL needsConfigSync){
OWSAssertIsOnMainThread();
[self versionMigrationsDidCompleteNeedingConfigSync:needsConfigSync];
}];
[SNConfiguration performMainSetup];
[SNAppearance switchToSessionAppearance];
if (CurrentAppContext().isRunningTests) {
return YES;
}
UIWindow *mainWindow = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
self.window = mainWindow;
CurrentAppContext().mainWindow = mainWindow;
// Show LoadingViewController until the async database view registrations are complete.
mainWindow.rootViewController = [LoadingViewController new];
[mainWindow makeKeyAndVisible];
LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault];
[self adaptAppMode:appMode];
if (@available(iOS 11, *)) {
// This must happen in appDidFinishLaunching or earlier to ensure we don't
// miss notifications.
// Setting the delegate also seems to prevent us from getting the legacy notification
// notification callbacks upon launch e.g. 'didReceiveLocalNotification'
UNUserNotificationCenter.currentNotificationCenter.delegate = self;
}
[OWSScreenLockUI.sharedManager setupWithRootWindow:self.window];
[[OWSWindowManager sharedManager] setupWithRootWindow:self.window
screenBlockingWindow:OWSScreenLockUI.sharedManager.screenBlockingWindow];
[OWSScreenLockUI.sharedManager startObserving];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(storageIsReady)
name:StorageIsReadyNotification
object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(registrationStateDidChange)
name:RegistrationStateDidChangeNotification
object:nil];
// Loki - Observe data nuke request notifications
[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(handleDataNukeRequested:) name:NSNotification.dataNukeRequested object:nil];
OWSLogInfo(@"application: didFinishLaunchingWithOptions completed.");
return YES;
}
- (void)applicationDidBecomeActive:(UIApplication *)application {
OWSAssertIsOnMainThread();
if (self.didAppLaunchFail) {
OWSFailDebug(@"App launch failed");
return;
}
if (CurrentAppContext().isRunningTests) {
return;
}
NSUserDefaults *sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.loki-project.loki-messenger"];
[sharedUserDefaults setBool:YES forKey:@"isMainAppActive"];
[sharedUserDefaults synchronize];
[self ensureRootViewController];
LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault];
[self adaptAppMode:appMode];
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[self handleActivation];
}];
// Clear all notifications whenever we become active.
// When opening the app from a notification,
// AppDelegate.didReceiveLocalNotification will always
// be called _before_ we become active.
[self clearAllNotificationsAndRestoreBadgeCount];
// On every activation, clear old temp directories.
ClearOldTemporaryDirectories();
}
- (void)applicationWillResignActive:(UIApplication *)application
{
OWSAssertIsOnMainThread();
if (self.didAppLaunchFail) {
OWSFailDebug(@"App launch failed");
return;
}
[self clearAllNotificationsAndRestoreBadgeCount];
NSUserDefaults *sharedUserDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.loki-project.loki-messenger"];
[sharedUserDefaults setBool:NO forKey:@"isMainAppActive"];
[sharedUserDefaults synchronize];
[DDLog flushLog];
}
#pragma mark - Orientation
- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(nullable UIWindow *)window
{
return UIInterfaceOrientationMaskPortrait;
}
#pragma mark - Background Fetching
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler
{
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[LKBackgroundPoller pollWithCompletionHandler:completionHandler];
}];
}
#pragma mark - App Readiness
/**
* The user must unlock the device once after reboot before the database encryption key can be accessed.
*/
- (void)verifyDBKeysAvailableBeforeBackgroundLaunch
{
if ([UIApplication sharedApplication].applicationState != UIApplicationStateBackground) { return; }
if (!OWSPrimaryStorage.isDatabasePasswordAccessible) {
OWSLogInfo(@"Exiting because we are in the background and the database password is not accessible.");
UILocalNotification *notification = [UILocalNotification new];
NSString *messageFormat = NSLocalizedString(@"NOTIFICATION_BODY_PHONE_LOCKED_FORMAT",
@"Lock screen notification text presented after user powers on their device without unlocking. Embeds "
@"{{device model}} (either 'iPad' or 'iPhone')");
notification.alertBody = [NSString stringWithFormat:messageFormat, UIDevice.currentDevice.localizedModel];
// Make sure we clear any existing notifications so that they don't start stacking up
// if the user receives multiple pushes.
[UIApplication.sharedApplication cancelAllLocalNotifications];
[UIApplication.sharedApplication setApplicationIconBadgeNumber:0];
[UIApplication.sharedApplication scheduleLocalNotification:notification];
[UIApplication.sharedApplication setApplicationIconBadgeNumber:1];
[DDLog flushLog];
exit(0);
}
}
- (void)enableBackgroundRefreshIfNecessary
{
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[UIApplication.sharedApplication setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
}];
}
- (void)handleActivation
{
OWSAssertIsOnMainThread();
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
if ([self.tsAccountManager isRegistered]) {
// At this point, potentially lengthy DB locking migrations could be running.
// Avoid blocking app launch by putting all further possible DB access in async block
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
OWSLogInfo(@"Running post launch block for registered user: %@.", [self.tsAccountManager localNumber]);
// Clean up any messages that expired since last launch immediately
// and continue cleaning in the background.
[self.disappearingMessagesJob startIfNecessary];
[self enableBackgroundRefreshIfNecessary];
// Mark all "attempting out" messages as "unsent", i.e. any messages that were not successfully
// sent before the app exited should be marked as failures.
[[[OWSFailedMessagesJob alloc] initWithPrimaryStorage:self.primaryStorage] run];
[[[OWSFailedAttachmentDownloadsJob alloc] initWithPrimaryStorage:self.primaryStorage] run];
});
}
}); // end dispatchOnce for first time we become active
// Every time we become active...
if ([self.tsAccountManager isRegistered]) {
// At this point, potentially lengthy DB locking migrations could be running.
// Avoid blocking app launch by putting all further possible DB access in async block
dispatch_async(dispatch_get_main_queue(), ^{
NSString *userPublicKey = self.tsAccountManager.localNumber;
// Update profile picture if needed
NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults;
NSDate *now = [NSDate new];
NSDate *lastProfilePictureUpload = (NSDate *)[userDefaults objectForKey:@"lastProfilePictureUpload"];
if (lastProfilePictureUpload != nil && [now timeIntervalSinceDate:lastProfilePictureUpload] > 14 * 24 * 60 * 60) {
// The user defaults flag is updated in ProfileManager
NSString *name = [SMKProfile fetchCurrentUserName];
UIImage *profilePicture = [SMKProfileManager profileAvatarWithRecipientId:userPublicKey];
[SMKProfileManager updateLocalWithProfileName:name avatarImage:profilePicture requiresSync:YES];
}
if (CurrentAppContext().isMainApp) {
[SNOpenGroupAPIV2 getDefaultRoomsIfNeeded];
}
[[SNSnodeAPI getSnodePool] retainUntilComplete];
[self startPollerIfNeeded];
[self startClosedGroupPoller];
[self startOpenGroupPollersIfNeeded];
if (![UIApplication sharedApplication].isRegisteredForRemoteNotifications) {
OWSLogInfo(@"Retrying remote notification registration since user hasn't registered yet.");
// Push tokens don't normally change while the app is launched, so checking once during launch is
// usually sufficient, but e.g. on iOS11, users who have disabled "Allow Notifications" and disabled
// "Background App Refresh" will not be able to obtain an APN token. Enabling those settings does not
// restart the app, so we check every activation for users who haven't yet registered.
__unused AnyPromise *promise =
[OWSSyncPushTokensJob runWithAccountManager:AppEnvironment.shared.accountManager
preferences:Environment.shared.preferences];
}
if (CurrentAppContext().isMainApp) {
[SNJobQueue.shared resumePendingJobs];
[self syncConfigurationIfNeeded];
}
});
}
}
- (void)versionMigrationsDidCompleteNeedingConfigSync:(BOOL)needsConfigSync
{
OWSAssertIsOnMainThread();
self.areVersionMigrationsComplete = YES;
// If we need a config sync then trigger it now
if (needsConfigSync) {
[SNMessageSender forceSyncConfigurationNow];
}
[self checkIfAppIsReady];
}
- (void)storageIsReady
{
OWSAssertIsOnMainThread();
[self checkIfAppIsReady];
}
- (void)checkIfAppIsReady
{
OWSAssertIsOnMainThread();
// App isn't ready until storage is ready AND all version migrations are complete
if (!self.areVersionMigrationsComplete) {
return;
}
if (![OWSStorage isStorageReady]) {
return;
}
if ([AppReadiness isAppReady]) {
// Only mark the app as ready once
return;
}
[SNConfiguration performMainSetup];
// Note that this does much more than set a flag;
// it will also run all deferred blocks.
[AppReadiness setAppIsReady];
if (CurrentAppContext().isRunningTests) { return; }
if ([self.tsAccountManager isRegistered]) {
// This should happen at any launch, background or foreground
__unused AnyPromise *pushTokenpromise =
[OWSSyncPushTokensJob runWithAccountManager:AppEnvironment.shared.accountManager
preferences:Environment.shared.preferences];
}
[DeviceSleepManager.sharedInstance removeBlockWithBlockObject:self];
[AppVersion.sharedInstance mainAppLaunchDidComplete];
[Environment.shared.audioSession setup];
[SSKEnvironment.shared.reachabilityManager setup];
if (!Environment.shared.preferences.hasGeneratedThumbnails) {
[self.primaryStorage.newDatabaseConnection
asyncReadWithBlock:^(YapDatabaseReadTransaction *_Nonnull transaction) {
[TSAttachmentStream enumerateCollectionObjectsUsingBlock:^(id _Nonnull obj, BOOL *_Nonnull stop){
// no-op. It's sufficient to initWithCoder: each object.
}];
}
completionBlock:^{
[Environment.shared.preferences setHasGeneratedThumbnails:YES];
}];
}
[self.readReceiptManager prepareCachedValues];
// Disable the SAE until the main app has successfully completed launch process
// at least once in the post-SAE world.
[OWSPreferences setIsReadyForAppExtensions];
[self ensureRootViewController];
[self preheatDatabaseViews];
[self.primaryStorage touchDbAsync];
// Every time the user upgrades to a new version:
//
// * Update account attributes.
// * Sync configuration.
if ([self.tsAccountManager isRegistered]) {
AppVersion *appVersion = AppVersion.sharedInstance;
if (appVersion.lastAppVersion.length > 0
&& ![appVersion.lastAppVersion isEqualToString:appVersion.currentAppVersion]) {
[[self.tsAccountManager updateAccountAttributes] retainUntilComplete];
}
}
}
- (void)preheatDatabaseViews
{
[self.primaryStorage.uiDatabaseConnection asyncReadWithBlock:^(YapDatabaseReadTransaction *transaction) {
for (NSString *viewName in @[
TSThreadDatabaseViewExtensionName,
TSMessageDatabaseViewExtensionName,
TSThreadOutgoingMessageDatabaseViewExtensionName,
TSUnreadDatabaseViewExtensionName,
TSUnseenDatabaseViewExtensionName,
]) {
YapDatabaseViewTransaction *databaseView = [transaction ext:viewName];
OWSAssertDebug([databaseView isKindOfClass:[YapDatabaseViewTransaction class]]);
}
}];
}
- (void)registrationStateDidChange
{
OWSAssertIsOnMainThread();
[self enableBackgroundRefreshIfNecessary];
if ([self.tsAccountManager isRegistered]) {
// Start running the disappearing messages job in case the newly registered user
// enables this feature
[self.disappearingMessagesJob startIfNecessary];
[self startPollerIfNeeded];
[self startClosedGroupPoller];
[self startOpenGroupPollersIfNeeded];
}
}
- (void)registrationLockDidChange:(NSNotification *)notification
{
[self enableBackgroundRefreshIfNecessary];
}
- (void)ensureRootViewController
{
OWSAssertIsOnMainThread();
if (!AppReadiness.isAppReady || self.hasInitialRootViewController) { return; }
self.hasInitialRootViewController = YES;
UIViewController *rootViewController;
BOOL navigationBarHidden = NO;
if ([self.tsAccountManager isRegistered]) {
rootViewController = [HomeVC new];
} else {
rootViewController = [LandingVC new];
navigationBarHidden = NO;
}
OWSAssertDebug(rootViewController);
OWSNavigationController *navigationController =
[[OWSNavigationController alloc] initWithRootViewController:rootViewController];
navigationController.navigationBarHidden = navigationBarHidden;
self.window.rootViewController = navigationController;
[UIViewController attemptRotationToDeviceOrientation];
}
#pragma mark - Notifications
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
OWSAssertIsOnMainThread();
if (self.didAppLaunchFail) {
OWSFailDebug(@"App launch failed");
return;
}
[self.pushRegistrationManager didReceiveVanillaPushToken:deviceToken];
OWSLogInfo(@"Registering for push notifications with token: %@.", deviceToken);
}
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
OWSAssertIsOnMainThread();
if (self.didAppLaunchFail) {
OWSFailDebug(@"App launch failed");
return;
}
OWSLogError(@"Failed to register push token with error: %@.", error);
#ifdef DEBUG
OWSLogWarn(@"We're in debug mode. Faking success for remote registration with a fake push identifier.");
[self.pushRegistrationManager didReceiveVanillaPushToken:[[NSMutableData dataWithLength:32] copy]];
#else
[self.pushRegistrationManager didFailToReceiveVanillaPushTokenWithError:error];
#endif
}
- (void)clearAllNotificationsAndRestoreBadgeCount
{
OWSAssertIsOnMainThread();
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
[AppEnvironment.shared.notificationPresenter clearAllNotifications];
[OWSMessageUtils.sharedManager updateApplicationBadgeCount];
}];
}
- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler
{
OWSAssertIsOnMainThread();
if (self.didAppLaunchFail) {
OWSFailDebug(@"App launch failed");
completionHandler(NO);
return;
}
[AppReadiness runNowOrWhenAppDidBecomeReady:^{
if (![self.tsAccountManager isRegisteredAndReady]) { return; }
[SignalApp.sharedApp.homeViewController createNewDM];
completionHandler(YES);
}];
}
// The method will be called on the delegate only if the application is in the foreground. If the method is not
// implemented or the handler is not called in a timely manner then the notification will not be presented. The
// application can choose to have the notification presented as a sound, badge, alert and/or in the notification list.
// This decision should be based on whether the information in the notification is otherwise visible to the user.
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler
__IOS_AVAILABLE(10.0)__TVOS_AVAILABLE(10.0)__WATCHOS_AVAILABLE(3.0)__OSX_AVAILABLE(10.14)
{
if (notification.request.content.userInfo[@"remote"]) {
OWSLogInfo(@"[Loki] Ignoring remote notifications while the app is in the foreground.");
return;
}
[AppReadiness runNowOrWhenAppDidBecomeReady:^() {
// We need to respect the in-app notification sound preference. This method, which is called
// for modern UNUserNotification users, could be a place to do that, but since we'd still
// need to handle this behavior for legacy UINotification users anyway, we "allow" all
// notification options here, and rely on the shared logic in NotificationPresenter to
// honor notification sound preferences for both modern and legacy users.
UNNotificationPresentationOptions options = UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound;
completionHandler(options);
}];
}
// The method will be called on the delegate when the user responded to the notification by opening the application,
// dismissing the notification or choosing a UNNotificationAction. The delegate must be set before the application
// returns from application:didFinishLaunchingWithOptions:.
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler __IOS_AVAILABLE(10.0)__WATCHOS_AVAILABLE(3.0)
__OSX_AVAILABLE(10.14)__TVOS_PROHIBITED
{
[AppReadiness runNowOrWhenAppDidBecomeReady:^() {
[self.userNotificationActionHandler handleNotificationResponse:response completionHandler:completionHandler];
}];
}
// The method will be called on the delegate when the application is launched in response to the user's request to view
// in-app notification settings. Add UNAuthorizationOptionProvidesAppNotificationSettings as an option in
// requestAuthorizationWithOptions:completionHandler: to add a button to inline notification settings view and the
// notification settings view in Settings. The notification will be nil when opened from Settings.
- (void)userNotificationCenter:(UNUserNotificationCenter *)center openSettingsForNotification:(nullable UNNotification *)notification __IOS_AVAILABLE(12.0)
__OSX_AVAILABLE(10.14)__WATCHOS_PROHIBITED __TVOS_PROHIBITED
{
}
#pragma mark - Polling
- (void)startPollerIfNeeded
{
if (self.poller == nil) {
NSString *userPublicKey = [SNGeneralUtilities getUserPublicKey];
if (userPublicKey != nil) {
self.poller = [[LKPoller alloc] init];
}
}
[self.poller startIfNeeded];
}
- (void)stopPoller { [self.poller stop]; }
- (void)startOpenGroupPollersIfNeeded
{
[SNOpenGroupManagerV2.shared startPolling];
}
- (void)stopOpenGroupPollers {
[SNOpenGroupManagerV2.shared stopPolling];
}
# pragma mark - App Mode
- (void)adaptAppMode:(LKAppMode)appMode
{
UIWindow *window = UIApplication.sharedApplication.keyWindow;
if (window == nil) { return; }
switch (appMode) {
case LKAppModeLight: {
if (@available(iOS 13.0, *)) {
window.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;
}
window.backgroundColor = UIColor.whiteColor;
break;
}
case LKAppModeDark: {
if (@available(iOS 13.0, *)) {
window.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
}
window.backgroundColor = UIColor.blackColor;
break;
}
}
if (LKAppModeUtilities.isSystemDefault) {
if (@available(iOS 13.0, *)) {
window.overrideUserInterfaceStyle = UIUserInterfaceStyleUnspecified;
}
}
[NSNotificationCenter.defaultCenter postNotificationName:NSNotification.appModeChanged object:nil];
}
- (void)setCurrentAppMode:(LKAppMode)appMode
{
[NSUserDefaults.standardUserDefaults setInteger:appMode forKey:@"appMode"];
[self adaptAppMode:appMode];
}
- (void)setAppModeToSystemDefault
{
[NSUserDefaults.standardUserDefaults removeObjectForKey:@"appMode"];
LKAppMode appMode = [LKAppModeManager getAppModeOrSystemDefault];
[self adaptAppMode:appMode];
}
# pragma mark - Other
- (void)handleDataNukeRequested:(NSNotification *)notification
{
NSUserDefaults *userDefaults = NSUserDefaults.standardUserDefaults;
BOOL isUsingFullAPNs = [userDefaults boolForKey:@"isUsingFullAPNs"];
NSString *hexEncodedDeviceToken = [userDefaults stringForKey:@"deviceToken"];
if (isUsingFullAPNs && hexEncodedDeviceToken != nil) {
NSData *deviceToken = [NSData dataFromHexString:hexEncodedDeviceToken];
[[LKPushNotificationAPI unregisterToken:deviceToken] retainUntilComplete];
}
[ThreadUtil deleteAllContent];
[SUKIdentity clearUserKeyPair];
[SNSnodeAPI clearSnodePool];
[self stopPoller];
[self stopClosedGroupPoller];
[self stopOpenGroupPollers];
BOOL wasUnlinked = [NSUserDefaults.standardUserDefaults boolForKey:@"wasUnlinked"];
[SignalApp resetAppData:^{
// Resetting the data clears the old user defaults. We need to restore the unlink default.
[NSUserDefaults.standardUserDefaults setBool:wasUnlinked forKey:@"wasUnlinked"];
}];
}
# pragma mark - App Link
- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:true];
// URL Scheme is sessionmessenger://DM?sessionID=1234
// We can later add more parameters like message etc.
NSString *intent = components.host;
if (intent != nil && [intent isEqualToString:@"DM"]) {
NSArray<NSURLQueryItem*> *params = [components queryItems];
NSPredicate *sessionIDPredicate = [NSPredicate predicateWithFormat:@"name == %@", @"sessionID"];
NSArray<NSURLQueryItem*> *matches = [params filteredArrayUsingPredicate:sessionIDPredicate];
if (matches.count > 0) {
NSString *sessionID = matches.firstObject.value;
[self createNewDMFromDeepLink:sessionID];
return YES;
}
}
return NO;
}
- (void)createNewDMFromDeepLink:(NSString *)sessionID
{
UIViewController *viewController = self.window.rootViewController;
if ([viewController class] == [OWSNavigationController class]) {
UIViewController *visibleVC = ((OWSNavigationController *)viewController).visibleViewController;
if ([visibleVC isKindOfClass:HomeVC.class]) {
HomeVC *homeVC = (HomeVC *)visibleVC;
[homeVC createNewDMFromDeepLink:sessionID];
}
}
}
@end

View File

@ -4,17 +4,472 @@ import Foundation
import PromiseKit
import SessionMessagingKit
import SessionUtilitiesKit
import SessionUIKit
import UserNotifications
import UIKit
import SignalUtilitiesKit
extension AppDelegate {
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, AppModeManagerDelegate {
var window: UIWindow?
var backgroundSnapshotBlockerWindow: UIWindow?
var appStartupWindow: UIWindow?
var poller: Poller = Poller()
// MARK: - Lifecycle
@objc(syncConfigurationIfNeeded)
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// These should be the first things we do (the startup process can fail without them)
SetCurrentAppContext(MainAppContext())
verifyDBKeysAvailableBeforeBackgroundLaunch()
AppModeManager.configure(delegate: self)
// OWSLinkPreview is now in SessionMessagingKit, so to still be able to deserialize them we
// need to tell NSKeyedUnarchiver about the changes.
// FIXME: Remove this once YapDatabase gets removed
NSKeyedUnarchiver.setClass(OWSLinkPreview.self, forClassName: "SessionServiceKit.OWSLinkPreview")
Cryptography.seedRandom()
AppVersion.sharedInstance() // TODO: ???
// Prevent the device from sleeping during database view async registration
// (e.g. long database upgrades).
//
// This block will be cleared in storageIsReady.
DeviceSleepManager.sharedInstance.addBlock(blockObject: self)
AppSetup.setupEnvironment(
appSpecificSingletonBlock: {
// Create AppEnvironment
AppEnvironment.shared.setup()
SignalApp.shared().setup()
},
migrationCompletion: { [weak self] successful, needsConfigSync in
guard let strongSelf = self else { return }
JobRunner.add(executor: SyncPushTokensJob.self, for: .syncPushTokens)
// Trigger any launch-specific jobs and start the JobRunner
JobRunner.appDidFinishLaunching()
// Note that this does much more than set a flag;
// it will also run all deferred blocks (including the JobRunner
// 'appDidBecomeActive' method)
AppReadiness.setAppIsReady()
DeviceSleepManager.sharedInstance.removeBlock(blockObject: strongSelf)
AppVersion.sharedInstance().mainAppLaunchDidComplete()
Environment.shared.audioSession.setup()
SSKEnvironment.shared.reachabilityManager.setup()
if !Environment.shared.preferences.hasGeneratedThumbnails() {
// Disable the SAE until the main app has successfully completed launch process
// at least once in the post-SAE world.
OWSPreferences.setIsReadyForAppExtensions()
// Setup the UI
self?.ensureRootViewController()
// Every time the user upgrades to a new version:
//
// * Update account attributes.
// * Sync configuration.
if Identity.userExists() {
// TODO: This
// AppVersion *appVersion = AppVersion.sharedInstance;
// if (appVersion.lastAppVersion.length > 0
// && ![appVersion.lastAppVersion isEqualToString:appVersion.currentAppVersion]) {
// [[self.tsAccountManager updateAccountAttributes] retainUntilComplete];
// }
}
// If we need a config sync then trigger it now
if (needsConfigSync) {
GRDBStorage.shared.write { db in
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
}
}
)
Configuration.performMainSetup()
SNAppearance.switchToSessionAppearance()
// No point continuing if we are running tests
guard !CurrentAppContext().isRunningTests else { return true }
let mainWindow: UIWindow = UIWindow(frame: UIScreen.main.bounds)
self.window = mainWindow
CurrentAppContext().mainWindow = mainWindow
// Show LoadingViewController until the async database view registrations are complete.
mainWindow.rootViewController = LoadingViewController()
mainWindow.makeKeyAndVisible()
adapt(appMode: AppModeManager.getAppModeOrSystemDefault())
// This must happen in appDidFinishLaunching or earlier to ensure we don't
// miss notifications.
// Setting the delegate also seems to prevent us from getting the legacy notification
// notification callbacks upon launch e.g. 'didReceiveLocalNotification'
UNUserNotificationCenter.current().delegate = self
OWSScreenLockUI.sharedManager().setup(withRootWindow: mainWindow)
OWSWindowManager.shared().setup(
withRootWindow: mainWindow,
screenBlockingWindow: OWSScreenLockUI.sharedManager().screenBlockingWindow
)
OWSScreenLockUI.sharedManager().startObserving()
NotificationCenter.default.addObserver(
self,
selector: #selector(registrationStateDidChange),
name: .registrationStateDidChange,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDataNukeRequested), // TODO: This differently???
name: .dataNukeRequested,
object: nil
)
Logger.info("application: didFinishLaunchingWithOptions completed.")
return true
}
func applicationDidEnterBackground(_ application: UIApplication) {
DDLog.flushLog()
stopPollers()
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
Logger.info("applicationDidReceiveMemoryWarning")
}
func applicationWillTerminate(_ application: UIApplication) {
DDLog.flushLog()
stopPollers()
}
func applicationDidBecomeActive(_ application: UIApplication) {
guard !CurrentAppContext().isRunningTests else { return }
// FIXME: We should move this somewhere to prevent typos from breaking it
let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger")
sharedUserDefaults?[.isMainAppActive] = true
ensureRootViewController()
adapt(appMode: AppModeManager.getAppModeOrSystemDefault())
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
self?.handleActivation()
}
// Clear all notifications whenever we become active.
// When opening the app from a notification,
// AppDelegate.didReceiveLocalNotification will always
// be called _before_ we become active.
clearAllNotificationsAndRestoreBadgeCount()
// On every activation, clear old temp directories.
ClearOldTemporaryDirectories();
}
func applicationWillResignActive(_ application: UIApplication) {
clearAllNotificationsAndRestoreBadgeCount()
let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.loki-project.loki-messenger")
sharedUserDefaults?[.isMainAppActive] = false
DDLog.flushLog()
}
// MARK: - Orientation
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
return .portrait
}
// MARK: - Background Fetching
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
BackgroundPoller.poll(completionHandler: completionHandler)
}
}
// MARK: - App Readiness
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
private func verifyDBKeysAvailableBeforeBackgroundLaunch() {
guard UIApplication.shared.applicationState == .background else { return }
let migrationHasRun: Bool = false
let databasePasswordAccessible: Bool = (
(migrationHasRun && GRDBStorage.isDatabasePasswordAccessible) || // GRDB password access
OWSStorage.isDatabasePasswordAccessible() // YapDatabase password access
)
guard !databasePasswordAccessible else { return } // All good
Logger.info("Exiting because we are in the background and the database password is not accessible.")
let notificationContent: UNMutableNotificationContent = UNMutableNotificationContent()
notificationContent.body = String(
format: NSLocalizedString("NOTIFICATION_BODY_PHONE_LOCKED_FORMAT", comment: ""),
UIDevice.current.localizedModel
)
let notificationRequest: UNNotificationRequest = UNNotificationRequest(
identifier: UUID().uuidString,
content: notificationContent,
trigger: nil
)
// Make sure we clear any existing notifications so that they don't start stacking up
// if the user receives multiple pushes.
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
UIApplication.shared.applicationIconBadgeNumber = 0
UNUserNotificationCenter.current().add(notificationRequest, withCompletionHandler: nil)
UIApplication.shared.applicationIconBadgeNumber = 1
DDLog.flushLog()
exit(0)
}
private func enableBackgroundRefreshIfNecessary() {
AppReadiness.runNowOrWhenAppDidBecomeReady {
UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
}
}
private func handleActivation() {
guard Identity.userExists() else { return }
enableBackgroundRefreshIfNecessary()
JobRunner.appDidBecomeActive()
SnodeAPI.getSnodePool().retainUntilComplete()
startPollersIfNeeded()
if CurrentAppContext().isMainApp {
syncConfigurationIfNeeded()
}
}
private func ensureRootViewController() {
// TODO: Add 'MigrationProcessingViewController' in here as well
guard self.window?.rootViewController is LoadingViewController else { return }
let navController: UINavigationController = OWSNavigationController(
rootViewController: (Identity.userExists() ?
HomeVC() :
LandingVC()
)
)
navController.isNavigationBarHidden = !(navController.viewControllers.first is HomeVC)
self.window?.rootViewController = navController
UIViewController.attemptRotationToDeviceOrientation()
}
// MARK: - Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushRegistrationManager.shared.didReceiveVanillaPushToken(deviceToken)
Logger.info("Registering for push notifications with token: \(deviceToken).")
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
Logger.error("Failed to register push token with error: \(error).")
#if DEBUG
Logger.warn("We're in debug mode. Faking success for remote registration with a fake push identifier.")
PushRegistrationManager.shared.didReceiveVanillaPushToken(Data(count: 32))
#else
PushRegistrationManager.shared.didFailToReceiveVanillaPushToken(error: error)
#endif
}
private func clearAllNotificationsAndRestoreBadgeCount() {
AppReadiness.runNowOrWhenAppDidBecomeReady {
AppEnvironment.shared.notificationPresenter.clearAllNotifications()
OWSMessageUtils.sharedManager().updateApplicationBadgeCount()
}
}
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
guard Identity.userExists() else { return }
SignalApp.shared().homeViewController?.createNewDM()
completionHandler(true)
}
}
/// The method will be called on the delegate only if the application is in the foreground. If the method is not implemented or the
/// handler is not called in a timely manner then the notification will not be presented. The application can choose to have the
/// notification presented as a sound, badge, alert and/or in the notification list.
///
/// This decision should be based on whether the information in the notification is otherwise visible to the user.
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
if notification.request.content.userInfo["remote"] != nil {
Logger.info("[Loki] Ignoring remote notifications while the app is in the foreground.")
return
}
AppReadiness.runNowOrWhenAppDidBecomeReady {
// We need to respect the in-app notification sound preference. This method, which is called
// for modern UNUserNotification users, could be a place to do that, but since we'd still
// need to handle this behavior for legacy UINotification users anyway, we "allow" all
// notification options here, and rely on the shared logic in NotificationPresenter to
// honor notification sound preferences for both modern and legacy users.
completionHandler([.alert, .badge, .sound])
}
}
/// The method will be called on the delegate when the user responded to the notification by opening the application, dismissing
/// the notification or choosing a UNNotificationAction. The delegate must be set before the application returns from
/// application:didFinishLaunchingWithOptions:.
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
AppEnvironment.shared.userNotificationActionHandler.handleNotificationResponse(response, completionHandler: completionHandler)
}
}
/// The method will be called on the delegate when the application is launched in response to the user's request to view in-app
/// notification settings. Add UNAuthorizationOptionProvidesAppNotificationSettings as an option in
/// requestAuthorizationWithOptions:completionHandler: to add a button to inline notification settings view and the notification
/// settings view in Settings. The notification will be nil when opened from Settings.
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
}
// MARK: - Notification Handling
@objc private func registrationStateDidChange() {
enableBackgroundRefreshIfNecessary()
guard Identity.userExists() else { return }
startPollersIfNeeded()
}
@objc public func handleDataNukeRequested() {
let isUsingFullAPNs: Bool = UserDefaults.standard[.isUsingFullAPNs]
let maybeDeviceToken: String? = UserDefaults.standard[.deviceToken]
// TODO: Clean up how this works
if isUsingFullAPNs, let deviceToken: String = maybeDeviceToken {
let data: Data = Data(hex: deviceToken)
PushNotificationAPI.unregister(data).retainUntilComplete()
}
ThreadUtil.deleteAllContent()
Identity.clearAll()
SnodeAPI.clearSnodePool()
stopPollers()
let wasUnlinked: Bool = UserDefaults.standard[.wasUnlinked]
SignalApp.resetAppData {
// Resetting the data clears the old user defaults. We need to restore the unlink default.
UserDefaults.standard[.wasUnlinked] = wasUnlinked
}
}
// MARK: - Polling
public func startPollersIfNeeded() {
guard Identity.userExists() else { return }
poller.startIfNeeded()
ClosedGroupPoller.shared.start()
OpenGroupManagerV2.shared.startPolling()
}
public func stopPollers() {
poller.stop()
ClosedGroupPoller.shared.stop()
OpenGroupManagerV2.shared.stopPolling()
}
// MARK: - App Mode
private func adapt(appMode: AppMode) {
guard let window: UIWindow = UIApplication.shared.keyWindow else { return }
switch (appMode) {
case .light:
window.overrideUserInterfaceStyle = .light
window.backgroundColor = .white
case .dark:
window.overrideUserInterfaceStyle = .dark
window.backgroundColor = .black
}
if LKAppModeUtilities.isSystemDefault {
window.overrideUserInterfaceStyle = .unspecified
}
NotificationCenter.default.post(name: .appModeChanged, object: nil)
}
func setCurrentAppMode(to appMode: AppMode) {
UserDefaults.standard[.appMode] = appMode.rawValue
adapt(appMode: appMode)
}
func setAppModeToSystemDefault() {
UserDefaults.standard.removeObject(forKey: SNUserDefaults.Int.appMode.rawValue)
adapt(appMode: AppModeManager.getAppModeOrSystemDefault())
}
// MARK: - App Link
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
guard let components: URLComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
return false
}
// URL Scheme is sessionmessenger://DM?sessionID=1234
// We can later add more parameters like message etc.
if components.host == "DM" {
let matches: [URLQueryItem] = (components.queryItems ?? [])
.filter { item in item.name == "sessionID" }
if let sessionId: String = matches.first?.value {
createNewDMFromDeepLink(sessionId: sessionId)
return true
}
}
return false
}
private func createNewDMFromDeepLink(sessionId: String) {
guard let homeViewController: HomeVC = (window?.rootViewController as? OWSNavigationController)?.visibleViewController as? HomeVC else {
return
}
homeViewController.createNewDMFromDeepLink(sessionID: sessionId)
}
// MARK: - Config Sync
func syncConfigurationIfNeeded() {
let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast)
guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days
GRDBStorage.shared.write { db in
MessageSender.syncConfiguration(db, forceSyncNow: false)
try MessageSender.syncConfiguration(db, forceSyncNow: false)
.done {
// Only update the 'lastConfigurationSync' timestamp if we have done the
// first sync (Don't want a new device config sync to override config
@ -26,14 +481,4 @@ extension AppDelegate {
.retainUntilComplete()
}
}
@objc func startClosedGroupPoller() {
guard Identity.userExists() else { return }
ClosedGroupPoller.shared.start()
}
@objc func stopClosedGroupPoller() {
ClosedGroupPoller.shared.stop()
}
}

View File

@ -61,7 +61,9 @@ import SignalUtilitiesKit
@objc
public func setup() {
// Hang certain singletons on SSKEnvironment too.
SSKEnvironment.shared.notificationsManager = notificationPresenter
SSKEnvironment.shared.notificationsManager.mutate {
$0 = notificationPresenter
}
setupLogFiles()
}

View File

@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
</dependencies>
<scenes/>
</document>

View File

@ -7,7 +7,6 @@
#import <SessionUIKit/SessionUIKit.h>
// Separate iOS Frameworks from other imports.
#import "AppDelegate.h"
#import "AvatarViewHelper.h"
#import "AVAudioSession+OWS.h"
#import "ContactCellView.h"
@ -25,10 +24,12 @@
#import "OWSMessageTimerView.h"
#import "OWSNavigationController.h"
#import "OWSProgressView.h"
#import "OWSScreenLockUI.h"
#import "OWSWindowManager.h"
#import "PrivacySettingsTableViewController.h"
#import "OWSQRCodeScanningViewController.h"
#import "SignalApp.h"
#import "MainAppContext.h"
#import "UIViewController+Permissions.h"
#import <PureLayout/PureLayout.h>
#import <Reachability/Reachability.h>

View File

@ -3,7 +3,6 @@
//
#import "SignalApp.h"
#import "AppDelegate.h"
#import "Session-Swift.h"
#import <SignalCoreKit/Threading.h>
#import <SessionMessagingKit/Environment.h>

View File

@ -607,7 +607,7 @@
"light_mode_theme" = "Light";
"PIN_BUTTON_TEXT" = "Pin";
"UNPIN_BUTTON_TEXT" = "Unpin";
"meida_saved" = "Media saved by %@.";
"media_saved" = "Media saved by %@.";
"screenshot_taken" = "%@ took a screenshot.";
"SEARCH_SECTION_CONTACTS" = "Contacts and Groups";
"SEARCH_SECTION_MESSAGES" = "Messages";

View File

@ -1,8 +0,0 @@
#import "AppDelegate.h"
int main(int argc, char *argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class));
}
}

View File

@ -3,6 +3,7 @@
//
import Foundation
import GRDB
import PromiseKit
import SessionMessagingKit
import SignalUtilitiesKit
@ -98,7 +99,7 @@ protocol NotificationPresenterAdaptee: AnyObject {
func notify(category: AppNotificationCategory, title: String?, body: String, userInfo: [AnyHashable: Any], sound: OWSSound?, replacingIdentifier: String?)
func cancelNotifications(threadId: String)
func cancelNotification(identifier: String)
func cancelNotifications(identifiers: [String])
func clearAllNotifications()
}
@ -154,74 +155,75 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
return adaptee.registerNotificationSettings()
}
public func notifyUser(for incomingMessage: TSIncomingMessage, in thread: TSThread, transaction: YapDatabaseReadTransaction) {
guard !thread.isMuted else { return }
guard let threadId = thread.uniqueId else { return }
let isMessageRequest = thread.isMessageRequest(using: transaction)
public func notifyUser(_ db: Database, for interaction: Interaction, in thread: SessionThread, isBackgroundPoll: Bool) {
guard thread.notificationMode != .none else { return }
let isMessageRequest = thread.isMessageRequest(db)
// If the thread is a message request and the user hasn't hidden message requests then we need
// to check if this is the only message request thread (group threads can't be message requests
// so just ignore those and if the user has hidden message requests then we want to show the
// notification regardless of how many message requests there are)
if !thread.isGroupThread() && isMessageRequest && !CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] {
let threads = transaction.ext(TSThreadDatabaseViewExtensionName) as! YapDatabaseViewTransaction
let numMessageRequests = threads.numberOfItems(inGroup: TSMessageRequestGroup)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard numMessageRequests == 0 else { return }
}
else if isMessageRequest && CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] {
// If there are other interactions on this thread already then don't show the notification
if thread.numberOfInteractions(with: transaction) > 1 { return }
CurrentAppContext().appUserDefaults()[.hasHiddenMessageRequests] = false
if thread.variant == .contact {
if isMessageRequest && !db[.hasHiddenMessageRequests] {
let numMessageRequestThreads: Int? = try? SessionThread.messageRequestThreads(db)
.fetchCount(db)
// Allow this to show a notification if there are no message requests (ie. this is the first one)
guard (numMessageRequestThreads ?? 0) == 0 else { return }
}
else if isMessageRequest && db[.hasHiddenMessageRequests] {
// If there are other interactions on this thread already then don't show the notification
if ((try? thread.interactions.fetchCount(db)) ?? 0) > 1 { return }
db[.hasHiddenMessageRequests] = false
}
}
let identifier: String = incomingMessage.notificationIdentifier ?? UUID().uuidString
let isBackgroudPoll = identifier == threadId
let identifier: String = interaction.notificationIdentifier(isBackgroundPoll: isBackgroundPoll)
// While batch processing, some of the necessary changes have not been commited.
let rawMessageText = incomingMessage.previewText(with: transaction)
let rawMessageText = interaction.previewText(db)
// iOS strips anything that looks like a printf formatting character from
// the notification body, so if we want to dispay a literal "%" in a notification
// it must be escaped.
// see https://developer.apple.com/documentation/uikit/uilocalnotification/1616646-alertbody
// for more details.
let messageText = DisplayableText.filterNotificationText(rawMessageText)
let messageText: String? = DisplayableText.filterNotificationText(rawMessageText)
// Don't fire the notification if the current user isn't mentioned
// and isOnlyNotifyingForMentions is on.
if let groupThread = thread as? TSGroupThread, groupThread.isOnlyNotifyingForMentions && !incomingMessage.isUserMentioned {
if thread.notificationMode == .mentionsOnly && !interaction.isUserMentioned(db) {
return
}
let senderName = Profile.displayName(for: incomingMessage.authorId, thread: thread)
let notificationTitle: String?
var notificationBody: String?
let previewType = preferences.notificationPreviewType(with: transaction)
let senderName = Profile.displayName(db, id: interaction.authorId, thread: thread)
let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType]
.defaulting(to: .nameAndPreview)
switch previewType {
case .noNameNoPreview:
notificationTitle = "Session"
case .nameNoPreview, .namePreview:
switch thread {
case is TSContactThread:
case .nameNoPreview, .nameAndPreview:
switch thread.variant {
case .contact:
notificationTitle = (isMessageRequest ? "Session" : senderName)
case is TSGroupThread:
var groupName = thread.name(with: transaction)
if groupName.count < 1 {
groupName = MessageStrings.newGroupDefaultTitle
}
notificationTitle = isBackgroudPoll ? groupName : String(format: NotificationStrings.incomingGroupMessageTitleFormat, senderName, groupName)
case .closedGroup, .openGroup:
let groupName: String = thread.name(db)
default:
owsFailDebug("unexpected thread: \(thread)")
return
notificationTitle = (isBackgroundPoll ? groupName:
String(
format: NotificationStrings.incomingGroupMessageTitleFormat,
senderName,
groupName
)
)
}
default:
@ -230,14 +232,14 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
switch previewType {
case .noNameNoPreview, .nameNoPreview: notificationBody = NotificationStrings.incomingMessageBody
case .namePreview: notificationBody = messageText
case .nameAndPreview: notificationBody = messageText
default: notificationBody = NotificationStrings.incomingMessageBody
}
// If it's a message request then overwrite the body to be something generic (only show a notification
// when receiving a new message request if there aren't any others or the user had hidden them)
if isMessageRequest {
notificationBody = NSLocalizedString("MESSAGE_REQUESTS_NOTIFICATION", comment: "")
notificationBody = "MESSAGE_REQUESTS_NOTIFICATION".localized()
}
assert((notificationBody ?? notificationTitle) != nil)
@ -247,11 +249,14 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
let category = AppNotificationCategory.incomingMessage
let userInfo = [
AppNotificationUserInfoKey.threadId: threadId
AppNotificationUserInfoKey.threadId: thread.id
]
DispatchQueue.main.async {
notificationBody = MentionUtilities.highlightMentions(in: notificationBody!, threadID: thread.uniqueId!)
notificationBody = MentionUtilities.highlightMentions(
in: (notificationBody ?? ""),
threadId: thread.id
)
let sound = self.requestSound(thread: thread)
self.adaptee.notify(
@ -265,42 +270,37 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
}
}
public func notifyForFailedSend(inThread thread: TSThread) {
public func notifyForFailedSend(_ db: Database, in thread: SessionThread) {
let notificationTitle: String?
switch previewType {
case .noNameNoPreview:
notificationTitle = nil
case .nameNoPreview, .namePreview:
notificationTitle = thread.name()
default:
notificationTitle = nil
case .noNameNoPreview: notificationTitle = nil
case .nameNoPreview, .namePreview: notificationTitle = thread.name(db)
default: notificationTitle = nil
}
let notificationBody = NotificationStrings.failedToSendBody
guard let threadId = thread.uniqueId else {
owsFailDebug("threadId was unexpectedly nil")
return
}
let userInfo = [
AppNotificationUserInfoKey.threadId: threadId
AppNotificationUserInfoKey.threadId: thread.id
]
DispatchQueue.main.async {
let sound = self.requestSound(thread: thread)
self.adaptee.notify(category: .errorMessage,
title: notificationTitle,
body: notificationBody,
userInfo: userInfo,
sound: sound)
self.adaptee.notify(
category: .errorMessage,
title: notificationTitle,
body: notificationBody,
userInfo: userInfo,
sound: sound
)
}
}
@objc
public func cancelNotification(_ identifier: String) {
public func cancelNotifications(identifiers: [String]) {
DispatchQueue.main.async {
self.adaptee.cancelNotification(identifier: identifier)
self.adaptee.cancelNotifications(identifiers: identifiers)
}
}
@ -318,12 +318,12 @@ public class NotificationPresenter: NSObject, NotificationsProtocol {
var mostRecentNotifications = TruncatedList<UInt64>(maxLength: kAudioNotificationsThrottleCount)
private func requestSound(thread: TSThread) -> OWSSound? {
private func requestSound(thread: SessionThread) -> OWSSound? {
guard checkIfShouldPlaySound() else {
return nil
}
return OWSSounds.notificationSound(for: thread)
return OWSSounds.notificationSound(forThreadId: thread.id)
}
private func checkIfShouldPlaySound() -> Bool {
@ -388,27 +388,31 @@ class NotificationActionHandler {
throw NotificationError.failDebug("threadId was unexpectedly nil")
}
guard let thread = TSThread.fetch(uniqueId: threadId) else {
guard let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: threadId) }) else {
throw NotificationError.failDebug("unable to find thread with id: \(threadId)")
}
return markAsRead(thread: thread).then { () -> Promise<Void> in
let message = VisibleMessage()
message.sentTimestamp = NSDate.millisecondTimestamp()
message.text = replyText
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
Storage.write { transaction in
tsMessage.save(with: transaction)
}
var promise: Promise<Void>!
Storage.writeSync { transaction in
promise = MessageSender.sendNonDurably(message, in: thread, using: transaction)
}
promise.catch { [weak self] error in
self?.notificationPresenter.notifyForFailedSend(inThread: thread)
}
return promise
let promise: Promise<Void> = GRDBStorage.shared.write { db in
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: getUserHexEncodedPublicKey(db),
variant: .standardOutgoing,
body: replyText,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
_ = try interaction.markingAsRead(db, includingOlder: true, trySendReadReceipt: true)
return MessageSender.sendNonDurably(db, interaction: interaction, in: thread)
}
promise.catch { [weak self] error in
GRDBStorage.shared.read { db in
self?.notificationPresenter.notifyForFailedSend(db, in: thread)
}
}
return promise
}
func showThread(userInfo: [AnyHashable: Any]) throws -> Promise<Void> {

View File

@ -1,107 +1,128 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import SignalUtilitiesKit
import SignalCoreKit
import SessionMessagingKit
import SessionUtilitiesKit
@objc(OWSSyncPushTokensJob)
class SyncPushTokensJob: NSObject {
@objc public static let PushTokensDidChange = Notification.Name("PushTokensDidChange")
// MARK: Dependencies
let accountManager: AccountManager
let preferences: OWSPreferences
var pushRegistrationManager: PushRegistrationManager {
return PushRegistrationManager.shared
}
@objc var uploadOnlyIfStale = true
@objc
required init(accountManager: AccountManager, preferences: OWSPreferences) {
self.accountManager = accountManager
self.preferences = preferences
}
class func run(accountManager: AccountManager, preferences: OWSPreferences) -> Promise<Void> {
let job = self.init(accountManager: accountManager, preferences: preferences)
return job.run()
}
func run() -> Promise<Void> {
let runPromise = firstly {
return self.pushRegistrationManager.requestPushTokens()
}.then { (pushToken: String, voipToken: String) -> Promise<Void> in
var shouldUploadTokens = false
if self.preferences.getPushToken() != pushToken || self.preferences.getVoipToken() != voipToken {
shouldUploadTokens = true
} else if !self.uploadOnlyIfStale {
shouldUploadTokens = true
}
if AppVersion.sharedInstance().lastAppVersion != AppVersion.sharedInstance().currentAppVersion {
shouldUploadTokens = true
}
guard shouldUploadTokens else {
return Promise.value(())
}
return firstly {
self.accountManager.updatePushTokens(pushToken: pushToken, voipToken: voipToken, isForcedUpdate: shouldUploadTokens)
}.done { _ in
self.recordPushTokensLocally(pushToken: pushToken, voipToken: voipToken)
}
public enum SyncPushTokensJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
// Don't schedule run when inactive or not in main app
var isMainAppActive = false
if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") {
isMainAppActive = sharedUserDefaults[.isMainAppActive]
}
runPromise.retainUntilComplete()
return runPromise
}
// MARK: - objc wrappers, since objc can't use swift parameterized types
@objc
class func run(accountManager: AccountManager, preferences: OWSPreferences) -> AnyPromise {
let promise: Promise<Void> = self.run(accountManager: accountManager, preferences: preferences)
return AnyPromise(promise)
}
@objc
func run() -> AnyPromise {
let promise: Promise<Void> = self.run()
return AnyPromise(promise)
}
// MARK:
private func recordPushTokensLocally(pushToken: String, voipToken: String) {
Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))")
var didTokensChange = false
if (pushToken != self.preferences.getPushToken()) {
Logger.info("Recording new plain push token")
self.preferences.setPushToken(pushToken)
didTokensChange = true
guard isMainAppActive else {
deferred(job) // Don't need to do anything if it's not the main app
return
}
if (voipToken != self.preferences.getVoipToken()) {
Logger.info("Recording new voip token")
self.preferences.setVoipToken(voipToken)
didTokensChange = true
// We need to check a UIApplication setting which needs to run on the main thread so if we aren't on
// the main thread then swap to it
guard Thread.isMainThread else {
DispatchQueue.main.async {
run(job, success: success, failure: failure, deferred: deferred)
}
return
}
if (didTokensChange) {
NotificationCenter.default.postNotificationNameAsync(SyncPushTokensJob.PushTokensDidChange, object: nil)
guard !UIApplication.shared.isRegisteredForRemoteNotifications else {
deferred(job) // Don't need to do anything if push notifications are already registered
return
}
Logger.info("Retrying remote notification registration since user hasn't registered yet.")
// Determine if we want to upload only if stale (Note: This should default to true, and be true if
// 'details' isn't provided)
// TODO: Double check on a real device
let uploadOnlyIfStale: Bool = ((try? JSONDecoder().decode(Details.self, from: job.details ?? Data()))?.uploadOnlyIfStale ?? true)
// Get the app version info (used to determine if we want to update the push tokens)
let lastAppVersion: String? = AppVersion.sharedInstance().lastAppVersion
let currentAppVersion: String? = AppVersion.sharedInstance().currentAppVersion
PushRegistrationManager.shared.requestPushTokens()
.then { (pushToken: String, voipToken: String) -> Promise<Void> in
let lastPushToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedPushToken] }
let lastVoipToken: String? = GRDBStorage.shared.read { db in db[.lastRecordedVoipToken] }
let shouldUploadTokens: Bool = (
!uploadOnlyIfStale || (
lastPushToken != pushToken ||
lastVoipToken != voipToken
) ||
lastAppVersion != currentAppVersion
)
guard shouldUploadTokens else { return Promise.value(()) }
let (promise, seal) = Promise<Void>.pending()
SSKEnvironment.shared.tsAccountManager
.registerForPushNotifications(
pushToken: pushToken,
voipToken: voipToken,
isForcedUpdate: shouldUploadTokens,
success: { seal.fulfill(()) },
failure: seal.reject
)
return promise
.done { _ in
Logger.warn("Recording push tokens locally. pushToken: \(redact(pushToken)), voipToken: \(redact(voipToken))")
GRDBStorage.shared.write { db in
db[.lastRecordedPushToken] = pushToken
db[.lastRecordedVoipToken] = voipToken
}
}
}
.ensure { success(job, false) } // We want to complete this job regardless of success or failure
.retainUntilComplete()
}
public static func run(uploadOnlyIfStale: Bool) {
guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: uploadOnlyIfStale)) else {
return
}
SyncPushTokensJob.run(
job,
success: { _, _ in },
failure: { _, _, _ in },
deferred: { _ in }
)
}
}
// MARK: - SyncPushTokensJob.Details
extension SyncPushTokensJob {
public struct Details: Codable {
public let uploadOnlyIfStale: Bool
}
}
// MARK: - Convenience
private func redact(_ string: String) -> String {
return OWSIsDebugBuild() ? string : "[ READACTED \(string.prefix(2))...\(string.suffix(2)) ]"
}
// MARK: - Objective C Support
@objc(OWSSyncPushTokensJob)
class OWSSyncPushTokensJob: NSObject {
@objc static func run() {
SyncPushTokensJob.run(uploadOnlyIfStale: false)
}
}

View File

@ -139,21 +139,21 @@ extension UserNotificationPresenterAdaptee: NotificationPresenterAdaptee {
let request = UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: trigger)
Logger.debug("presenting notification with identifier: \(notificationIdentifier)")
if isReplacingNotification { cancelNotification(identifier: notificationIdentifier) }
if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) }
notificationCenter.add(request)
notifications[notificationIdentifier] = request
}
func cancelNotification(identifier: String) {
func cancelNotifications(identifiers: [String]) {
AssertIsOnMainThread()
notifications.removeValue(forKey: identifier)
notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier])
notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier])
identifiers.forEach { notifications.removeValue(forKey: $0) }
notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiers)
}
func cancelNotification(_ notification: UNNotificationRequest) {
AssertIsOnMainThread()
cancelNotification(identifier: notification.identifier)
cancelNotifications(identifiers: [notification.identifier])
}
func cancelNotifications(threadId: String) {

View File

@ -97,8 +97,7 @@ final class PNModeVC : BaseVC, OptionViewDelegate {
TSAccountManager.sharedInstance().didRegister()
let homeVC = HomeVC()
navigationController!.setViewControllers([ homeVC ], animated: true)
let syncTokensJob = SyncPushTokensJob(accountManager: AppEnvironment.shared.accountManager, preferences: Environment.shared.preferences)
syncTokensJob.uploadOnlyIfStale = false
let _: Promise<Void> = syncTokensJob.run()
SyncPushTokensJob.run(uploadOnlyIfStale: false)
}
}

View File

@ -117,9 +117,7 @@
- (void)didToggleAPNsSwitch:(UISwitch *)sender
{
[NSUserDefaults.standardUserDefaults setBool:sender.on forKey:@"isUsingFullAPNs"];
OWSSyncPushTokensJob *syncTokensJob = [[OWSSyncPushTokensJob alloc] initWithAccountManager:AppEnvironment.shared.accountManager preferences:Environment.shared.preferences];
syncTokensJob.uploadOnlyIfStale = NO;
[[syncTokensJob run] retainUntilComplete];
[OWSSyncPushTokensJob run]; // FIXME: Only usage of 'OWSSyncPushTokensJob' - remove when gone
}
@end

View File

@ -125,7 +125,7 @@ final class NukeDataModal : Modal {
@objc private func clearDeviceOnly() {
ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: false) { [weak self] _ in
GRDBStorage.shared.write { db in
MessageSender.syncConfiguration(db, forceSyncNow: true)
try MessageSender.syncConfiguration(db, forceSyncNow: true)
.ensure(on: DispatchQueue.main) {
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
UserDefaults.removeAll() // Not done in the nuke data implementation as unlinking requires this to happen later

View File

@ -14,6 +14,7 @@
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SignalUtilitiesKit/ThreadUtil.h>
#import <SessionMessagingKit/OWSReadReceiptManager.h>
#import <SessionMessagingKit/SessionMessagingKit-Swift.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
@ -54,23 +55,6 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
#pragma mark - Dependencies
- (OWSPreferences *)preferences
{
return Environment.shared.preferences;
}
- (OWSReadReceiptManager *)readReceiptManager
{
return OWSReadReceiptManager.sharedManager;
}
- (id<OWSTypingIndicators>)typingIndicators
{
return SSKEnvironment.shared.typingIndicators;
}
#pragma mark - Table Contents
- (void)updateTableContents
@ -107,7 +91,7 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s
@"Label for the 'typing indicators' setting.")
accessibilityIdentifier:[NSString stringWithFormat:@"settings.privacy.%@", @"typing_indicators"]
isOnBlock:^{
return [SSKEnvironment.shared.typingIndicators areTypingIndicatorsEnabled];
return [SSKPreferences areTypingIndicatorsEnabled];
}
isEnabledBlock:^{
return YES;
@ -236,21 +220,21 @@ static NSString *const kSealedSenderInfoURL = @"https://signal.org/blog/sealed-s
{
BOOL enabled = sender.isOn;
OWSLogInfo(@"toggled screen security: %@", enabled ? @"ON" : @"OFF");
[self.preferences setScreenSecurity:enabled];
[SSKPreferences setScreenSecurity:enabled];
}
- (void)didToggleReadReceiptsSwitch:(UISwitch *)sender
{
BOOL enabled = sender.isOn;
OWSLogInfo(@"toggled areReadReceiptsEnabled: %@", enabled ? @"ON" : @"OFF");
[self.readReceiptManager setAreReadReceiptsEnabled:enabled];
[SSKPreferences setAreReadReceiptsEnabled:enabled];
}
- (void)didToggleTypingIndicatorsSwitch:(UISwitch *)sender
{
BOOL enabled = sender.isOn;
OWSLogInfo(@"toggled areTypingIndicatorsEnabled: %@", enabled ? @"ON" : @"OFF");
[self.typingIndicators setTypingIndicatorsEnabledWithValue:enabled];
[SSKPreferences setTypingIndicatorsEnabled:enabled];
}
- (void)didToggleLinkPreviewsEnabled:(UISwitch *)sender

View File

@ -359,7 +359,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate {
private func updateProfile(isUpdatingDisplayName: Bool, isUpdatingProfilePicture: Bool) {
let userDefaults = UserDefaults.standard
let name: String? = (displayNameToBeUploaded ?? Profile.fetchOrCreateCurrentUser().name)
let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(for: getUserHexEncodedPublicKey()))
let profilePicture: UIImage? = (profilePictureToBeUploaded ?? ProfileManager.profileAvatar(id: getUserHexEncodedPublicKey()))
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, canCancel: false) { [weak self, displayNameToBeUploaded, profilePictureToBeUploaded] modalActivityIndicator in
ProfileManager.updateLocal(
profileName: (name ?? ""),
@ -373,7 +373,7 @@ final class SettingsVC : BaseVC, AvatarViewHelperDelegate {
userDefaults[.lastProfilePictureUpdate] = Date()
}
GRDBStorage.shared.write { db in
MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
DispatchQueue.main.async {

View File

@ -1,21 +1,17 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SignalUtilitiesKit
final class ConversationCell : UITableViewCell {
var isShowingGlobalSearchResult = false
var threadViewModel: ThreadViewModel! {
didSet {
isShowingGlobalSearchResult ? updateForSearchResult() : update()
}
}
static let reuseIdentifier = "ConversationCell"
// MARK: UI Components
private let accentLineView = UIView()
private lazy var profilePictureView = ProfilePictureView()
private lazy var displayNameLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.mediumFontSize)
@ -23,7 +19,7 @@ final class ConversationCell : UITableViewCell {
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var unreadCountView: UIView = {
let result = UIView()
result.backgroundColor = Colors.text.withAlphaComponent(Values.veryLowOpacity)
@ -34,7 +30,7 @@ final class ConversationCell : UITableViewCell {
result.layer.cornerRadius = size / 2
return result
}()
private lazy var unreadCountLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
@ -42,7 +38,7 @@ final class ConversationCell : UITableViewCell {
result.textAlignment = .center
return result
}()
private lazy var hasMentionView: UIView = {
let result = UIView()
result.backgroundColor = Colors.accent
@ -53,7 +49,7 @@ final class ConversationCell : UITableViewCell {
result.layer.cornerRadius = size / 2
return result
}()
private lazy var hasMentionLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
@ -62,7 +58,7 @@ final class ConversationCell : UITableViewCell {
result.textAlignment = .center
return result
}()
private lazy var isPinnedIcon: UIImageView = {
let result = UIImageView(image: UIImage(named: "Pin")!.withRenderingMode(.alwaysTemplate))
result.contentMode = .scaleAspectFit
@ -73,7 +69,7 @@ final class ConversationCell : UITableViewCell {
result.layer.masksToBounds = true
return result
}()
private lazy var timestampLabel: UILabel = {
let result = UILabel()
result.font = .systemFont(ofSize: Values.smallFontSize)
@ -82,7 +78,7 @@ final class ConversationCell : UITableViewCell {
result.alpha = Values.lowOpacity
return result
}()
private lazy var snippetLabel: UILabel = {
let result = UILabel()
result.font = .systemFont(ofSize: Values.smallFontSize)
@ -90,9 +86,9 @@ final class ConversationCell : UITableViewCell {
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var typingIndicatorView = TypingIndicatorView()
private lazy var statusIndicatorView: UIImageView = {
let result = UIImageView()
result.contentMode = .scaleAspectFit
@ -100,7 +96,7 @@ final class ConversationCell : UITableViewCell {
result.layer.masksToBounds = true
return result
}()
private lazy var topLabelStackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
@ -108,7 +104,7 @@ final class ConversationCell : UITableViewCell {
result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer
return result
}()
private lazy var bottomLabelStackView: UIStackView = {
let result = UIStackView()
result.axis = .horizontal
@ -116,23 +112,23 @@ final class ConversationCell : UITableViewCell {
result.spacing = Values.smallSpacing / 2 // Effectively Values.smallSpacing because there'll be spacing before and after the invisible spacer
return result
}()
// MARK: Settings
public static let unreadCountViewSize: CGFloat = 20
private static let statusIndicatorSize: CGFloat = 14
// MARK: Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
let cellHeight: CGFloat = 68
// Background color
@ -203,8 +199,21 @@ final class ConversationCell : UITableViewCell {
stackView.set(.height, to: cellHeight)
}
// MARK: Updating for search results
private func updateForSearchResult() {
// MARK: - Content
public func update(with threadViewModel: ThreadViewModel?, isGlobalSearchResult: Bool = false) {
guard let threadViewModel: ThreadViewModel = threadViewModel else { return }
guard !isGlobalSearchResult else {
updateForSearchResult(threadViewModel)
return
}
update(threadViewModel)
}
// MARK: - Updating for search results
private func updateForSearchResult(_ threadViewModel: ThreadViewModel) {
AssertIsOnMainThread()
guard let thread = threadViewModel?.threadRecord else { return }
profilePictureView.update(for: thread)
@ -212,15 +221,15 @@ final class ConversationCell : UITableViewCell {
unreadCountView.isHidden = true
hasMentionView.isHidden = true
}
public func configureForRecent() {
displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text])
public func configureForRecent(_ threadViewModel: ThreadViewModel) {
displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(for: threadViewModel.thread), attributes: [.foregroundColor:Colors.text])
bottomLabelStackView.isHidden = false
let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate))
let snippet = String(format: NSLocalizedString("RECENT_SEARCH_LAST_MESSAGE_DATETIME", comment: ""), DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate))
snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)])
timestampLabel.isHidden = true
}
public func configure(snippet: String?, searchText: String, message: TSMessage? = nil) {
let normalizedSearchText = searchText.lowercased()
if let messageTimestamp = message?.timestamp, let snippet = snippet {
@ -263,150 +272,182 @@ final class ConversationCell : UITableViewCell {
timestampLabel.isHidden = true
}
}
private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString {
guard snippet != NSLocalizedString("NOTE_TO_SELF", comment: "") else {
return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text])
}
let result = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)])
let normalizedSnippet = snippet.lowercased() as NSString
guard normalizedSnippet.contains(searchText) else { return result }
let range = normalizedSnippet.range(of: searchText)
result.addAttribute(.foregroundColor, value: Colors.text, range: range)
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: range)
return result
}
// MARK: - Updating
// MARK: Updating
private func update() {
AssertIsOnMainThread()
guard let thread = threadViewModel?.threadRecord else { return }
backgroundColor = threadViewModel.isPinned ? Colors.cellPinned : Colors.cellBackground
private func update(_ threadViewModel: ThreadViewModel) {
let thread: SessionThread = threadViewModel.thread
if thread.isBlocked() {
backgroundColor = (thread.isPinned ? Colors.cellPinned : Colors.cellBackground)
if GRDBStorage.shared.read({ db in try thread.contact.fetchOne(db)?.isBlocked }) == true {
accentLineView.backgroundColor = Colors.destructive
accentLineView.alpha = 1
}
else {
accentLineView.backgroundColor = Colors.accent
accentLineView.alpha = threadViewModel.hasUnreadMessages ? 1 : 0.0001 // Setting the alpha to exactly 0 causes an issue on iOS 12
accentLineView.alpha = (threadViewModel.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12
}
isPinnedIcon.isHidden = !threadViewModel.isPinned
unreadCountView.isHidden = !threadViewModel.hasUnreadMessages
let unreadCount = threadViewModel.unreadCount
unreadCountLabel.text = unreadCount < 10000 ? "\(unreadCount)" : "9999+"
let fontSize = (unreadCount < 10000) ? Values.verySmallFontSize : 8
unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
hasMentionView.isHidden = !(threadViewModel.hasUnreadMentions && thread.isGroupThread())
profilePictureView.update(for: thread)
displayNameLabel.text = getDisplayName()
timestampLabel.text = DateUtil.formatDate(forDisplay: threadViewModel.lastMessageDate)
if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil {
snippetLabel.text = ""
typingIndicatorView.isHidden = false
typingIndicatorView.startAnimation()
} else {
snippetLabel.attributedText = getSnippet()
isPinnedIcon.isHidden = !thread.isPinned
unreadCountView.isHidden = (threadViewModel.unreadCount <= 0)
unreadCountLabel.text = (threadViewModel.unreadCount < 10000 ? "\(threadViewModel.unreadCount)" : "9999+")
unreadCountLabel.font = .boldSystemFont(
ofSize: (threadViewModel.unreadCount < 10000 ? Values.verySmallFontSize : 8)
)
hasMentionView.isHidden = !(
(threadViewModel.unreadMentionCount > 0) &&
(thread.variant == .closedGroup || thread.variant == .openGroup)
)
GRDBStorage.shared.read { db in profilePictureView.update(db, thread: thread) }
displayNameLabel.text = getDisplayName(for: thread)
timestampLabel.text = DateUtil.formatDate(forDisplay: threadViewModel.lastInteractionDate)
// TODO: Add this back
// if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil {
// snippetLabel.text = ""
// typingIndicatorView.isHidden = false
// typingIndicatorView.startAnimation()
// } else {
snippetLabel.attributedText = getSnippet(threadViewModel: threadViewModel)
typingIndicatorView.isHidden = true
typingIndicatorView.stopAnimation()
}
// }
statusIndicatorView.backgroundColor = nil
let lastMessage = threadViewModel.lastMessageForInbox
if let lastMessage = lastMessage as? TSOutgoingMessage {
let status = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: lastMessage)
switch (threadViewModel.lastInteraction?.variant, threadViewModel.lastInteractionState) {
case (.standardOutgoing, .sending):
statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.text
statusIndicatorView.isHidden = false
case (.standardOutgoing, .sent):
statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.text
statusIndicatorView.isHidden = false
case (.standardOutgoing, .failed):
statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.destructive
statusIndicatorView.isHidden = false
switch status {
case .uploading, .sending:
statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.text
case .sent, .skipped, .delivered:
statusIndicatorView.image = #imageLiteral(resourceName: "CircleCheck").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.text
case .read:
statusIndicatorView.image = isLightMode ? #imageLiteral(resourceName: "FilledCircleCheckLightMode") : #imageLiteral(resourceName: "FilledCircleCheckDarkMode")
statusIndicatorView.tintColor = nil
statusIndicatorView.backgroundColor = (isLightMode ? .black : .white)
case .failed:
statusIndicatorView.image = #imageLiteral(resourceName: "message_status_failed").withRenderingMode(.alwaysTemplate)
statusIndicatorView.tintColor = Colors.destructive
}
statusIndicatorView.isHidden = false
}
else {
statusIndicatorView.isHidden = true
default:
statusIndicatorView.isHidden = false
}
}
private func getMessageAuthorName(message: TSMessage) -> String? {
guard threadViewModel.isGroupThread else { return nil }
if let incomingMessage = message as? TSIncomingMessage {
return Profile.displayName(for: incomingMessage.authorId, customFallback: "Anonymous")
private func getAuthorName(thread: SessionThread, interaction: Interaction) -> String? {
switch (thread.variant, interaction.variant) {
case (.contact, .standardIncoming):
return Profile.displayName(id: interaction.authorId, customFallback: "Anonymous")
default: return nil
}
return nil
}
private func getDisplayNameForSearch(_ sessionID: String) -> String {
if threadViewModel.threadRecord.isNoteToSelf() {
return NSLocalizedString("NOTE_TO_SELF", comment: "")
}
return [
Profile.displayName(for: sessionID),
Profile.displayName(id: sessionID),
Profile.fetchOrCreate(id: sessionID).nickname.map { "(\($0)" }
]
.compactMap { $0 }
.joined(separator: " ")
}
private func getDisplayName() -> String {
if threadViewModel.isGroupThread {
if threadViewModel.name.isEmpty {
return "Unknown Group"
}
else {
return threadViewModel.name
}
private func getDisplayName(for thread: SessionThread) -> String {
if thread.variant == .closedGroup || thread.variant == .openGroup {
return GRDBStorage.shared.read({ db in thread.name(db) })
.defaulting(to: "Unknown Group")
}
else {
if threadViewModel.threadRecord.isNoteToSelf() {
return NSLocalizedString("NOTE_TO_SELF", comment: "")
}
else {
let hexEncodedPublicKey: String = threadViewModel.contactSessionID!
let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))"
return Profile.displayName(for: hexEncodedPublicKey, customFallback: middleTruncatedHexKey)
}
if GRDBStorage.shared.read({ db in thread.isNoteToSelf(db) }) == true {
return "NOTE_TO_SELF".localized()
}
let hexEncodedPublicKey: String = thread.id
let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))"
return Profile.displayName(id: hexEncodedPublicKey, customFallback: middleTruncatedHexKey)
}
private func getSnippet() -> NSMutableAttributedString {
private func getSnippet(threadViewModel: ThreadViewModel) -> NSMutableAttributedString {
let result = NSMutableAttributedString()
if threadViewModel.isMuted {
result.append(NSAttributedString(string: "\u{e067} ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ]))
} else if threadViewModel.isOnlyNotifyingForMentions {
if (threadViewModel.thread.notificationMode == .none) {
result.append(NSAttributedString(
string: "\u{e067} ",
attributes: [
.font: UIFont.ows_elegantIconsFont(10),
.foregroundColor :Colors.unimportant
]
))
}
else if threadViewModel.thread.notificationMode == .mentionsOnly {
let imageAttachment = NSTextAttachment()
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant)
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
let imageString = NSAttributedString(attachment: imageAttachment)
result.append(imageString)
result.append(NSAttributedString(string: " ", attributes: [ .font : UIFont.ows_elegantIconsFont(10), .foregroundColor : Colors.unimportant ]))
result.append(NSAttributedString(
string: " ",
attributes: [
.font: UIFont.ows_elegantIconsFont(10),
.foregroundColor: Colors.unimportant
]
))
}
let font = threadViewModel.hasUnreadMessages ? UIFont.boldSystemFont(ofSize: Values.smallFontSize) : UIFont.systemFont(ofSize: Values.smallFontSize)
if threadViewModel.isGroupThread, let message = threadViewModel.lastMessageForInbox as? TSMessage, let name = getMessageAuthorName(message: message) {
result.append(NSAttributedString(string: "\(name): ", attributes: [ .font : font, .foregroundColor : Colors.text ]))
let font: UIFont = (threadViewModel.unreadCount > 0 ?
.boldSystemFont(ofSize: Values.smallFontSize) :
.systemFont(ofSize: Values.smallFontSize)
)
if
(threadViewModel.thread.variant == .closedGroup || threadViewModel.thread.variant == .openGroup),
let lastInteraction: Interaction = threadViewModel.lastInteraction,
let authorName: String = getAuthorName(thread: threadViewModel.thread, interaction: lastInteraction)
{
result.append(NSAttributedString(
string: "\(authorName): ",
attributes: [
.font: font,
.foregroundColor: Colors.text
]
))
}
if let rawSnippet = threadViewModel.lastMessageText {
let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadID: threadViewModel.threadRecord.uniqueId!)
result.append(NSAttributedString(string: snippet, attributes: [ .font : font, .foregroundColor : Colors.text ]))
if let rawSnippet: String = threadViewModel.lastInteractionText {
let snippet = MentionUtilities.highlightMentions(in: rawSnippet, threadId: threadViewModel.thread.id)
result.append(NSAttributedString(
string: snippet,
attributes: [
.font: font,
.foregroundColor: Colors.text
]
))
}
return result
}
}

View File

@ -82,7 +82,7 @@ final class UserCell : UITableViewCell {
func update() {
profilePictureView.publicKey = publicKey
profilePictureView.update()
displayNameLabel.text = Profile.displayName(for: publicKey)
displayNameLabel.text = Profile.displayName(id: publicKey)
switch accessory {
case .none: accessoryImageView.isHidden = true

View File

@ -85,9 +85,21 @@ public class AccountManager: NSObject {
private func syncPushTokens() -> Promise<Void> {
Logger.info("")
let job = SyncPushTokensJob(accountManager: self, preferences: self.preferences)
job.uploadOnlyIfStale = false
return job.run()
guard let job: Job = Job(variant: .syncPushTokens, details: SyncPushTokensJob.Details(uploadOnlyIfStale: false)) else {
return Promise(error: GRDBStorageError.decodingFailed)
}
let (promise, seal) = Promise<Void>.pending()
SyncPushTokensJob.run(
job,
success: { _, _ in seal.fulfill(()) },
failure: { _, error, _ in seal.reject(error ?? GRDBStorageError.generic) },
deferred: { _ in seal.reject(GRDBStorageError.generic) }
)
return promise
}
private func completeRegistration() {

View File

@ -4,12 +4,17 @@ public final class MentionUtilities : NSObject {
override private init() { }
@objc public static func highlightMentions(in string: String, threadID: String) -> String {
return highlightMentions(in: string, isOutgoingMessage: false, threadID: threadID, attributes: [:]).string // isOutgoingMessage and attributes are irrelevant
@objc public static func highlightMentions(in string: String, threadId: String) -> String {
return highlightMentions(
in: string,
isOutgoingMessage: false,
threadId: threadId,
attributes: [:]
).string // isOutgoingMessage and attributes are irrelevant
}
@objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadID: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString {
let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID)
@objc public static func highlightMentions(in string: String, isOutgoingMessage: Bool, threadId: String, attributes: [NSAttributedString.Key:Any]) -> NSAttributedString {
let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadId)
var string = string
let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: [])
var mentions: [(range: NSRange, publicKey: String)] = []
@ -17,7 +22,7 @@ public final class MentionUtilities : NSObject {
while let match = outerMatch {
let publicKey = String((string as NSString).substring(with: match.range).dropFirst()) // Drop the @
let matchEnd: Int
if let displayName = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) {
if let displayName = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) {
string = (string as NSString).replacingCharacters(in: match.range, with: "@\(displayName)")
mentions.append((range: NSRange(location: match.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @
matchEnd = match.range.location + displayName.utf16.count

View File

@ -1,33 +1,32 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
import UIKit
@objc public extension UIApplication {
public var frontmostViewControllerIgnoringAlerts: UIViewController? {
var frontmostViewControllerIgnoringAlerts: UIViewController? {
return findFrontmostViewController(ignoringAlerts: true)
}
public var frontmostViewController: UIViewController? {
var frontmostViewController: UIViewController? {
return findFrontmostViewController(ignoringAlerts: false)
}
internal func findFrontmostViewController(ignoringAlerts: Bool) -> UIViewController? {
guard let window = CurrentAppContext().mainWindow else {
return nil
}
guard let window: UIWindow = CurrentAppContext().mainWindow else { return nil }
Logger.error("findFrontmostViewController: \(window)")
guard let viewController = window.rootViewController else {
guard let viewController: UIViewController = window.rootViewController else {
owsFailDebug("Missing root view controller.")
return nil
}
return viewController.findFrontmostViewController(ignoringAlerts)
}
public func openSystemSettings() {
func openSystemSettings() {
openURL(URL(string: UIApplication.openSettingsURLString)!)
}
}

View File

@ -18,16 +18,26 @@ public enum SNMessagingKit { // Just to make the external API nice
identifier: .messagingKit,
migrations: [
[
_001_InitialSetupMigration.self
_001_InitialSetupMigration.self,
_002_SetupStandardJobs.self
],
[
_002_YDBToGRDBMigration.self
_003_YDBToGRDBMigration.self
]
]
)
}
public static func configure(storage: SessionMessagingKitStorageProtocol) {
// Configure the job executors
JobRunner.add(executor: DisappearingMessagesJob.self, for: .disappearingMessages)
JobRunner.add(executor: FailedMessagesJob.self, for: .failedMessages)
JobRunner.add(executor: FailedAttachmentDownloadsJob.self, for: .failedAttachmentDownloads)
JobRunner.add(executor: MessageSendJob.self, for: .messageSend)
JobRunner.add(executor: MessageReceiveJob.self, for: .messageReceive)
JobRunner.add(executor: NotifyPushServerJob.self, for: .notifyPushServer)
JobRunner.add(executor: SendReadReceiptsJob.self, for: .sendReadReceipts)
SNMessagingKitConfiguration.shared = SNMessagingKitConfiguration(storage: storage)
}
}

View File

@ -30,7 +30,30 @@ public enum Legacy {
internal static let interactionCollection = "TSInteraction"
internal static let attachmentsCollection = "TSAttachements"
internal static let readReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection"
internal static let outgoingReadReceiptManagerCollection = "kOutgoingReadReceiptManagerCollection"
internal static let notifyPushServerJobCollection = "NotifyPNServerJobCollection"
internal static let messageReceiveJobCollection = "MessageReceiveJobCollection"
internal static let messageSendJobCollection = "MessageSendJobCollection"
internal static let attachmentUploadJobCollection = "AttachmentUploadJobCollection"
internal static let attachmentDownloadJobCollection = "AttachmentDownloadJobCollection"
internal static let preferencesCollection = "SignalPreferences"
internal static let preferencesKeyNotificationPreviewType = "preferencesKeyNotificationPreviewType"
internal static let preferencesKeyScreenSecurityDisabled = "Screen Security Key"
internal static let preferencesKeyLastRecordedPushToken = "LastRecordedPushToken"
internal static let preferencesKeyLastRecordedVoipToken = "LastRecordedVoipToken"
internal static let readReceiptManagerCollection = "OWSReadReceiptManagerCollection"
internal static let readReceiptManagerAreReadReceiptsEnabled = "areReadReceiptsEnabled"
internal static let typingIndicatorsCollection = "TypingIndicators"
internal static let typingIndicatorsEnabledKey = "kDatabaseKey_TypingIndicatorsEnabled"
internal static let soundsStorageNotificationCollection = "kOWSSoundsStorageNotificationCollection"
internal static let soundsGlobalNotificationKey = "kOWSSoundsStorageGlobalNotificationKey"
internal static let userDefaultsHasHiddenMessageRequests = "hasHiddenMessageRequests"
// MARK: - Types
@ -43,67 +66,276 @@ public enum Legacy {
public var profileKey: Data?
public var profilePictureURL: String?
internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) {
self.displayName = displayName
self.profileKey = profileKey
self.profilePictureURL = profilePictureURL
}
@objc(NotifyPNServerJob)
internal final class NotifyPNServerJob: NSObject, NSCoding {
@objc(SnodeMessage)
internal final class SnodeMessage: NSObject, NSCoding {
public let recipient: String
public let data: LosslessStringConvertible
public let ttl: UInt64
public let timestamp: UInt64 // Milliseconds
public required init?(coder: NSCoder) {
if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName }
if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey }
if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL }
}
public func encode(with coder: NSCoder) {
coder.encode(displayName, forKey: "displayName")
coder.encode(profileKey, forKey: "profileKey")
coder.encode(profilePictureURL, forKey: "profilePictureURL")
}
public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? {
guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil }
let profileKey = proto.profileKey
let profilePictureURL = profileProto.profilePicture
if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL)
} else {
return Profile(displayName: displayName)
// MARK: - Coding
public init?(coder: NSCoder) {
guard
let recipient = coder.decodeObject(forKey: "recipient") as! String?,
let data = coder.decodeObject(forKey: "data") as! String?,
let ttl = coder.decodeObject(forKey: "ttl") as! UInt64?,
let timestamp = coder.decodeObject(forKey: "timestamp") as! UInt64?
else { return nil }
self.recipient = recipient
self.data = data
self.ttl = ttl
self.timestamp = timestamp
super.init()
}
}
public func toProto() -> SNProtoDataMessage? {
guard let displayName = displayName else {
SNLog("Couldn't construct profile proto from: \(self).")
return nil
}
let dataMessageProto = SNProtoDataMessage.builder()
let profileProto = SNProtoDataMessageLokiProfile.builder()
profileProto.setDisplayName(displayName)
if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
dataMessageProto.setProfileKey(profileKey)
profileProto.setProfilePicture(profilePictureURL)
}
do {
dataMessageProto.setProfile(try profileProto.build())
return try dataMessageProto.build()
} catch {
SNLog("Couldn't construct profile proto from: \(self).")
return nil
public func encode(with coder: NSCoder) {
coder.encode(recipient, forKey: "recipient")
coder.encode(data, forKey: "data")
coder.encode(ttl, forKey: "ttl")
coder.encode(timestamp, forKey: "timestamp")
}
}
// MARK: Description
public override var description: String {
"""
Profile(
displayName: \(displayName ?? "null"),
profileKey: \(profileKey?.description ?? "null"),
profilePictureURL: \(profilePictureURL ?? "null")
)
"""
public let message: SnodeMessage
public var id: String?
public var failureCount: UInt = 0
// MARK: - Coding
public init?(coder: NSCoder) {
guard
let message = coder.decodeObject(forKey: "message") as! SnodeMessage?,
let id = coder.decodeObject(forKey: "id") as! String?
else { return nil }
self.message = message
self.id = id
self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0)
}
public func encode(with coder: NSCoder) {
coder.encode(message, forKey: "message")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
}
@objc(MessageReceiveJob)
public final class MessageReceiveJob: NSObject, NSCoding {
public let data: Data
public let serverHash: String?
public let openGroupMessageServerID: UInt64?
public let openGroupID: String?
public let isBackgroundPoll: Bool
public var id: String?
public var failureCount: UInt = 0
// MARK: - Coding
public init?(coder: NSCoder) {
guard
let data = coder.decodeObject(forKey: "data") as! Data?,
let id = coder.decodeObject(forKey: "id") as! String?,
let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool?
else { return nil }
self.data = data
self.serverHash = coder.decodeObject(forKey: "serverHash") as! String?
self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64?
self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String?
self.isBackgroundPoll = isBackgroundPoll
self.id = id
self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0)
}
public func encode(with coder: NSCoder) {
coder.encode(data, forKey: "data")
coder.encode(serverHash, forKey: "serverHash")
coder.encode(openGroupMessageServerID, forKey: "openGroupMessageServerID")
coder.encode(openGroupID, forKey: "openGroupID")
coder.encode(isBackgroundPoll, forKey: "isBackgroundPoll")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
}
@objc(SNMessageSendJob)
public final class MessageSendJob: NSObject, NSCoding {
public let message: Message
public let destination: Message.Destination
public var id: String?
public var failureCount: UInt = 0
// MARK: - Coding
public init?(coder: NSCoder) {
guard let message = coder.decodeObject(forKey: "message") as! Message?,
let rawDestination = coder.decodeObject(forKey: "destination") as! String?,
let id = coder.decodeObject(forKey: "id") as! String?
else { return nil }
self.message = message
if let destString: String = MessageSendJob.process(rawDestination, type: "contact") {
destination = .contact(publicKey: destString)
}
else if let destString: String = MessageSendJob.process(rawDestination, type: "closedGroup") {
destination = .closedGroup(groupPublicKey: destString)
}
else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroup") {
let components = destString
.split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard components.count == 2, let channel = UInt64(components[0]) else { return nil }
let server = components[1]
destination = .openGroup(channel: channel, server: server)
}
else if let destString: String = MessageSendJob.process(rawDestination, type: "openGroupV2") {
let components = destString
.split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard components.count == 2 else { return nil }
let room = components[0]
let server = components[1]
destination = .openGroupV2(room: room, server: server)
}
else {
return nil
}
self.id = id
self.failureCount = ((coder.decodeObject(forKey: "failureCount") as? UInt) ?? 0)
}
public func encode(with coder: NSCoder) {
coder.encode(message, forKey: "message")
switch destination {
case .contact(let publicKey):
coder.encode("contact(\(publicKey))", forKey: "destination")
case .closedGroup(let groupPublicKey):
coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination")
case .openGroup(let channel, let server):
coder.encode("openGroup(\(channel), \(server))", forKey: "destination")
case .openGroupV2(let room, let server):
coder.encode("openGroupV2(\(room), \(server))", forKey: "destination")
}
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
// MARK: - Convenience
private static func process(_ value: String, type: String) -> String? {
guard value.hasPrefix("\(type)(") else { return nil }
guard value.hasSuffix(")") else { return nil }
var updatedValue: String = value
updatedValue.removeFirst("\(type)(".count)
updatedValue.removeLast(")".count)
return updatedValue
}
}
@objc(AttachmentUploadJob)
public final class AttachmentUploadJob: NSObject, NSCoding {
public let attachmentID: String
public let threadID: String
public let message: Message
public let messageSendJobID: String
public var id: String?
public var failureCount: UInt = 0
// MARK: - Coding
public init?(coder: NSCoder) {
guard
let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
let message = coder.decodeObject(forKey: "message") as! Message?,
let messageSendJobID = coder.decodeObject(forKey: "messageSendJobID") as! String?,
let id = coder.decodeObject(forKey: "id") as! String?
else { return nil }
self.attachmentID = attachmentID
self.threadID = threadID
self.message = message
self.messageSendJobID = messageSendJobID
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
}
public func encode(with coder: NSCoder) {
coder.encode(attachmentID, forKey: "attachmentID")
coder.encode(threadID, forKey: "threadID")
coder.encode(message, forKey: "message")
coder.encode(messageSendJobID, forKey: "messageSendJobID")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
}
@objc(AttachmentDownloadJob)
public final class AttachmentDownloadJob: NSObject, NSCoding {
public let attachmentID: String
public let tsMessageID: String
public let threadID: String
public var id: String?
public var failureCount: UInt = 0
public var isDeferred = false
// MARK: - Coding
public init?(coder: NSCoder) {
guard
let attachmentID = coder.decodeObject(forKey: "attachmentID") as! String?,
let tsMessageID = coder.decodeObject(forKey: "tsIncomingMessageID") as! String?,
let threadID = coder.decodeObject(forKey: "threadID") as! String?,
let id = coder.decodeObject(forKey: "id") as! String?
else { return nil }
self.attachmentID = attachmentID
self.tsMessageID = tsMessageID
self.threadID = threadID
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
self.isDeferred = coder.decodeBool(forKey: "isDeferred")
}
public func encode(with coder: NSCoder) {
coder.encode(attachmentID, forKey: "attachmentID")
coder.encode(tsMessageID, forKey: "tsIncomingMessageID")
coder.encode(threadID, forKey: "threadID")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
coder.encode(isDeferred, forKey: "isDeferred")
}
}
}
@objc(SNJob)
public protocol _LegacyJob : NSCoding {
var id: String? { get set }
var failureCount: UInt { get set }
static var collection: String { get }
static var maxFailureCount: UInt { get }
func execute()
}
// Note: Looks like Swift doesn't expose nested types well (in the `-Swift` header this was

View File

@ -52,6 +52,7 @@ enum _001_InitialSetupMigration: Migration {
t.column(.notificationMode, .integer)
.notNull()
.defaults(to: SessionThread.NotificationMode.all)
t.column(.notificationSound, .integer)
t.column(.mutedUntilTimestamp, .double)
}
@ -139,10 +140,14 @@ enum _001_InitialSetupMigration: Migration {
t.column(.variant, .integer).notNull()
t.column(.body, .text)
t.column(.timestampMs, .double)
t.column(.timestampMs, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.receivedAtTimestampMs, .double).notNull()
t.column(.receivedAtTimestampMs, .integer).notNull()
t.column(.wasRead, .boolean)
.notNull()
.indexed() // Quicker querying
.defaults(to: false)
t.column(.expiresInSeconds, .double)
t.column(.expiresStartedAtMs, .double)
t.column(.linkPreviewUrl, .text)
@ -154,20 +159,26 @@ enum _001_InitialSetupMigration: Migration {
.defaults(to: false)
t.column(.openGroupWhisperTo, .text)
// Null is not unique in SQLite which allows us to do this and we do
// a joint constraint with the `threadId` on the off chance there is
// a collision between different hashes on different servers
t.uniqueKey([.threadId, .serverHash])
// The `openGroupServerMessageId` is unique on a per-thread basis
/// Note: The below unique constraints are added to prevent messages being duplicated, we need
/// multiple constraints because `null` is not unique in SQLite which means any unique constraint
/// which contained a nullable column would not be seen as unique if the value is null (this is good to
/// avoid outgoing message from conflicting due to not having a `serverHash` but bad when different
/// columns are only unique in certain circumstances)
///
/// The values have the following behaviours:
///
/// Threads with variants: [`contact`, `closedGroup`]:
/// `threadId` - Unique per thread
/// `serverHash` - Unique per message for service-node-based messages
/// **Note:** Some InfoMessage's will have this intentionally left `null`
/// as we want to ignore any collisions and re-process them
/// `timestampMs` - Very low chance of collision (especially combined with other two)
///
/// Threads with variants: [`openGroup`]:
/// `threadId` - Unique per thread
/// `openGroupServerMessageId` - Unique for VisibleMessage's on an OpenGroup server
t.uniqueKey([.threadId, .serverHash, .timestampMs])
t.uniqueKey([.threadId, .openGroupServerMessageId])
// Note: The timestamp will be unique on a per-message basis so we
// need to add the below unique constraint to handle cases where
// the `serverHash` and `openGroupServerMessageId` can both be null
// to try and prevent duplicate messages (it's theoretically possible
// to get a collision with this constraint but is astronomically unlikely)
t.uniqueKey([.threadId, .serverHash, .openGroupServerMessageId, .timestampMs])
}
try db.create(table: RecipientState.self) { t in
@ -177,47 +188,28 @@ enum _001_InitialSetupMigration: Migration {
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.recipientId, .text)
.notNull()
.indexed() // Quicker querying
.references(Profile.self)
t.column(.state, .integer).notNull()
t.column(.state, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.readTimestampMs, .double)
t.column(.mostRecentFailureText, .text)
// We want to ensure that a recipient can only have a single state for
// each interaction
t.uniqueKey([.interactionId, .recipientId])
}
try db.create(table: Quote.self) { t in
t.column(.interactionId, .integer)
.notNull()
.primaryKey()
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.authorId, .text)
.notNull()
.references(Profile.self)
t.column(.timestampMs, .double).notNull()
t.column(.body, .text)
}
try db.create(table: LinkPreview.self) { t in
t.column(.url, .text)
.notNull()
.indexed() // Quicker querying
t.column(.timestamp, .double)
.notNull()
.indexed() // Quicker querying
t.column(.variant, .integer).notNull()
t.column(.title, .text)
t.primaryKey([.url, .timestamp])
t.primaryKey([.interactionId, .recipientId])
}
try db.create(table: Attachment.self) { t in
t.column(.interactionId, .integer)
.indexed() // Quicker querying
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.id, .text)
.notNull()
.primaryKey()
t.column(.serverId, .text)
t.column(.variant, .integer).notNull()
t.column(.state, .integer).notNull()
t.column(.state, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.contentType, .text).notNull()
t.column(.byteCount, .integer)
.notNull()
@ -231,5 +223,75 @@ enum _001_InitialSetupMigration: Migration {
t.column(.digest, .blob)
t.column(.caption, .text)
}
try db.create(table: InteractionAttachment.self) { t in
t.column(.interactionId, .integer)
.notNull()
.indexed() // Quicker querying
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.attachmentId, .text)
.notNull()
.indexed() // Quicker querying
.references(Attachment.self, onDelete: .cascade) // Delete if attachment deleted
}
try db.create(table: Quote.self) { t in
t.column(.interactionId, .integer)
.notNull()
.primaryKey()
.references(Interaction.self, onDelete: .cascade) // Delete if interaction deleted
t.column(.authorId, .text)
.notNull()
.references(Profile.self)
t.column(.timestampMs, .double).notNull()
t.column(.body, .text)
t.column(.attachmentId, .text)
.references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted
}
try db.create(table: LinkPreview.self) { t in
t.column(.url, .text)
.notNull()
.indexed() // Quicker querying
t.column(.timestamp, .double)
.notNull()
.indexed() // Quicker querying
t.column(.variant, .integer).notNull()
t.column(.title, .text)
t.column(.attachmentId, .text)
.references(Attachment.self, onDelete: .setNull) // Clear if attachment deleted
t.primaryKey([.url, .timestamp])
}
try db.create(table: ControlMessageProcessRecord.self) { t in
t.column(.threadId, .text).notNull()
t.column(.sentTimestampMs, .integer).notNull()
t.column(.serverHash, .text).notNull()
t.column(.openGroupMessageServerId, .integer).notNull()
t.uniqueKey([.threadId, .sentTimestampMs, .serverHash, .openGroupMessageServerId])
}
try db.create(table: Job.self) { t in
t.column(.id, .integer)
.notNull()
.primaryKey(autoincrement: true)
t.column(.failureCount, .integer)
.notNull()
.defaults(to: 0)
t.column(.variant, .integer)
.notNull()
.indexed() // Quicker querying
t.column(.behaviour, .integer).notNull() // TODO: Indexed???
t.column(.nextRunTimestamp, .double)
.notNull() // TODO: Should this just be nullable??? (or do we want to fetch by this?)
.indexed() // Quicker querying
.defaults(to: 0)
t.column(.threadId, .text)
.indexed() // Quicker querying
.references(SessionThread.self, onDelete: .cascade) // Delete if thread deleted
t.column(.details, .blob)
}
}
}

View File

@ -0,0 +1,49 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Curve25519Kit
import SessionUtilitiesKit
import SessionSnodeKit
/// This migration sets up the standard jobs, since we want these jobs to run before any "once-off" jobs we do this migration
/// before running the `YDBToGRDBMigration`
enum _002_SetupStandardJobs: Migration {
static let identifier: String = "SetupStandardJobs"
static func migrate(_ db: Database) throws {
// Start by adding the jobs that don't have collections (in the jobs like these
// will be added via migrations)
try autoreleasepool {
// TODO: Add additional jobs from the AppDelegate
_ = try Job(
failureCount: 0,
variant: .disappearingMessages,
behaviour: .recurringOnLaunch,
nextRunTimestamp: 0
).inserted(db)
_ = try Job(
failureCount: 0,
variant: .failedMessages,
behaviour: .recurringOnLaunch,
nextRunTimestamp: 0
).inserted(db)
_ = try Job(
failureCount: 0,
variant: .failedAttachmentDownloads,
behaviour: .recurringOnLaunch,
nextRunTimestamp: 0
).inserted(db)
// Note: This job exists in the 'Session' target but that doesn't have it's own migrations
_ = try Job(
failureCount: 0,
variant: .syncPushTokens,
behaviour: .recurringOnLaunch,
nextRunTimestamp: 0
).inserted(db)
}
}
}

View File

@ -4,13 +4,15 @@ import Foundation
import GRDB
import Curve25519Kit
import SessionUtilitiesKit
import SessionSnodeKit
enum _002_YDBToGRDBMigration: Migration {
// Note: Looks like the oldest iOS device we support (min iOS 13.0) has 2Gb of RAM, processing
// ~250k messages and ~1000 threads seems to take up
enum _003_YDBToGRDBMigration: Migration {
static let identifier: String = "YDBToGRDBMigration"
// TODO: Autorelease pool???.
static func migrate(_ db: Database) throws {
// MARK: - Contacts & Threads
// MARK: - Process Contacts, Threads & Interactions
var shouldFailMigration: Bool = false
var contacts: Set<Legacy.Contact> = []
@ -30,10 +32,11 @@ enum _002_YDBToGRDBMigration: Migration {
var openGroupImage: [String: Data] = [:]
var openGroupLastMessageServerId: [String: Int64] = [:] // Optional
var openGroupLastDeletionServerId: [String: Int64] = [:] // Optional
// var openGroupServerToUniqueIdLookup: [String: [String]] = [:] // TODO: Not needed????
var interactions: [String: [TSInteraction]] = [:]
var attachments: [String: TSAttachment] = [:]
var readReceipts: [String: [Double]] = [:]
var outgoingReadReceiptsTimestampsMs: [String: Set<Int64>] = [:]
Storage.read { transaction in
// Process the Contacts
@ -85,6 +88,7 @@ enum _002_YDBToGRDBMigration: Migration {
return
}
guard userClosedGroupPublicKeys.contains(publicKey) else {
// TODO: Determine if we want to remove this
SNLog("[Migration Error] Found unexpected invalid closed group public key")
shouldFailMigration = true
return
@ -151,7 +155,109 @@ enum _002_YDBToGRDBMigration: Migration {
}
print("RAWR [\(Date().timeIntervalSince1970)] - Process attachments - End")
// Process read receipts
transaction.enumerateKeysAndObjects(inCollection: Legacy.outgoingReadReceiptManagerCollection) { key, object, _ in
guard let timestampsMs: Set<Int64> = object as? Set<Int64> else { return }
outgoingReadReceiptsTimestampsMs[key] = (outgoingReadReceiptsTimestampsMs[key] ?? Set())
.union(timestampsMs)
}
/*
guard let view = transaction.ext(viewName) as? YapDatabaseAutoViewTransaction else {
owsFailDebug("Could not load view.")
return
}
guard let group = group else {
owsFailDebug("No group.")
return
}
// Deserializing interactions is expensive, so we only
// do that when necessary.
let sortIdForItemId: (String) -> UInt64? = { (itemId) in
guard let interaction = TSInteraction.fetch(uniqueId: itemId, transaction: transaction) else {
owsFailDebug("Could not load interaction.")
return nil
}
return interaction.sortId
}
self.viewName = TSMessageDatabaseViewExtensionName
self.group = group
// If we have a "pivot", load all items AFTER the pivot and up to minDesiredLength items BEFORE the pivot.
// If we do not have a "pivot", load up to minDesiredLength BEFORE the pivot.
var newItemIds = [ItemId]()
var canLoadMore = false
let desiredLength = self.desiredLength
// Not all items "count" towards the desired length. On an initial load, all items count. Subsequently,
// only items above the pivot count.
var afterPivotCount: UInt = 0
var beforePivotCount: UInt = 0
// (void (^)(NSString *collection, NSString *key, id object, NSUInteger index, BOOL *stop))block;
view.enumerateKeys(inGroup: group, with: NSEnumerationOptions.reverse) { (_, key, _, stop) in
let itemId = key
// Load "uncounted" items after the pivot if possible.
//
// As an optimization, we can skip this check (which requires
// deserializing the interaction) if beforePivotCount is non-zero,
// e.g. after we "pass" the pivot.
if beforePivotCount == 0,
let pivotSortId = self.pivotSortId {
if let sortId = sortIdForItemId(itemId) {
let isAfterPivot = sortId > pivotSortId
if isAfterPivot {
newItemIds.append(itemId)
afterPivotCount += 1
return
}
} else {
owsFailDebug("Could not determine sort id for interaction: \(itemId)")
}
}
// Load "counted" items unless the load window overflows.
if beforePivotCount >= desiredLength {
// Overflow
canLoadMore = true
stop.pointee = true
} else {
newItemIds.append(itemId)
beforePivotCount += 1
}
}
NSMutableSet<NSString *> *interactionIds = [NSMutableSet new];
[self.uiDatabaseConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSMutableArray<TSInteraction *> *interactions = [NSMutableArray new];
YapDatabaseViewTransaction *viewTransaction = [transaction ext:TSMessageDatabaseViewExtensionName];
OWSAssertDebug(viewTransaction);
for (NSString *uniqueId in loadedUniqueIds) {
TSInteraction *_Nullable interaction =
[TSInteraction fetchObjectWithUniqueID:uniqueId transaction:transaction];
if (!interaction) {
OWSFailDebug(@"missing interaction in message mapping: %@.", uniqueId);
hasError = YES;
continue;
}
if (!interaction.uniqueId) {
OWSFailDebug(@"invalid interaction in message mapping: %@.", interaction);
hasError = YES;
continue;
}
[interactions addObject:interaction];
if ([interactionIds containsObject:interaction.uniqueId]) {
OWSFailDebug(@"Duplicate interaction: %@", interaction.uniqueId);
continue;
}
[interactionIds addObject:interaction.uniqueId];
}
for (TSInteraction *interaction in interactions) {
tryToAddViewItem(interaction, transaction);
}
}];
*/
}
// We can't properly throw within the 'enumerateKeysAndObjects' block so have to throw here
@ -168,6 +274,7 @@ enum _002_YDBToGRDBMigration: Migration {
let isCurrentUser: Bool = (contact.sessionID == currentUserPublicKey)
let contactThreadId: String = TSContactThread.threadID(fromContactSessionID: contact.sessionID)
// TODO: Contact 'hasOne' profile???
// Create the "Profile" for the legacy contact
try Profile(
id: contact.sessionID,
@ -188,6 +295,7 @@ enum _002_YDBToGRDBMigration: Migration {
contact.isBlocked ||
contact.hasBeenBlocked {
// Create the contact
// TODO: Closed group admins???
try Contact(
id: contact.sessionID,
isTrusted: (isCurrentUser || contact.isTrusted),
@ -203,6 +311,29 @@ enum _002_YDBToGRDBMigration: Migration {
// MARK: - Insert Threads
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - Start")
var legacyThreadIdToIdMap: [String: String] = [:]
var legacyInteractionToIdMap: [String: Int64] = [:]
var legacyInteractionIdentifierToIdMap: [String: Int64] = [:]
func identifier(for threadId: String, sentTimestamp: UInt64, recipients: [String], destination: Message.Destination? = nil) -> String {
let recipientString: String = {
if let destination: Message.Destination = destination {
switch destination {
case .contact(let publicKey): return publicKey
default: break
}
}
return (recipients.first ?? "0")
}()
return [
"\(sentTimestamp)",
recipientString,
threadId
]
.joined(separator: "-")
}
try threads.forEach { thread in
guard let legacyThreadId: String = thread.uniqueId else { return }
@ -246,6 +377,8 @@ enum _002_YDBToGRDBMigration: Migration {
}
try autoreleasepool {
legacyThreadIdToIdMap[thread.uniqueId ?? ""] = id
try SessionThread(
id: id,
variant: variant,
@ -327,11 +460,11 @@ enum _002_YDBToGRDBMigration: Migration {
room: openGroup.room,
publicKey: openGroup.publicKey,
name: openGroup.name,
groupDescription: nil, // TODO: Add with SOGS V4
imageId: nil, // TODO: Add with SOGS V4
groupDescription: nil, // TODO: Add with SOGS V4.
imageId: nil, // TODO: Add with SOGS V4.
imageData: openGroupImage[legacyThreadId],
userCount: (openGroupUserCount[legacyThreadId] ?? 0), // Will be updated next poll
infoUpdates: 0 // TODO: Add with SOGS V4
infoUpdates: 0 // TODO: Add with SOGS V4.
).insert(db)
}
}
@ -346,10 +479,12 @@ enum _002_YDBToGRDBMigration: Migration {
let variant: Interaction.Variant
let authorId: String
let body: String?
let wasRead: Bool
let expiresInSeconds: UInt32?
let expiresStartedAtMs: UInt64?
let openGroupServerMessageId: UInt64?
let recipientStateMap: [String: TSOutgoingMessageRecipientState]?
let mostRecentFailureText: String?
let quotedMessage: TSQuotedMessage?
let linkPreview: OWSLinkPreview?
let linkPreviewVariant: LinkPreview.Variant
@ -414,18 +549,22 @@ enum _002_YDBToGRDBMigration: Migration {
)
authorId = incomingMessage.authorId
body = incomingMessage.body
wasRead = incomingMessage.wasRead
expiresInSeconds = incomingMessage.expiresInSeconds
expiresStartedAtMs = incomingMessage.expireStartedAt
recipientStateMap = [:]
mostRecentFailureText = nil
case let outgoingMessage as TSOutgoingMessage:
variant = .standardOutgoing
authorId = currentUserPublicKey
body = outgoingMessage.body
wasRead = true // Outgoing messages are read by default
expiresInSeconds = outgoingMessage.expiresInSeconds
expiresStartedAtMs = outgoingMessage.expireStartedAt
recipientStateMap = outgoingMessage.recipientStateMap
mostRecentFailureText = outgoingMessage.mostRecentFailureText
case let infoMessage as TSInfoMessage:
authorId = currentUserPublicKey
@ -433,9 +572,11 @@ enum _002_YDBToGRDBMigration: Migration {
infoMessage.customMessage :
infoMessage.body
)
wasRead = infoMessage.wasRead
expiresInSeconds = nil // Info messages don't expire
expiresStartedAtMs = nil // Info messages don't expire
recipientStateMap = [:]
mostRecentFailureText = nil
switch infoMessage.messageType {
case .groupCreated: variant = .infoClosedGroupCreated
@ -452,34 +593,48 @@ enum _002_YDBToGRDBMigration: Migration {
}
default:
// TODO: What message types have no body?
SNLog("[Migration Error] Unsupported interaction type")
throw GRDBStorageError.migrationFailed
}
// Insert the data
let interaction = try Interaction(
let interaction: Interaction = try Interaction(
serverHash: serverHash,
threadId: id,
authorId: authorId,
variant: variant,
body: body,
timestampMs: Double(legacyInteraction.timestamp),
receivedAtTimestampMs: Double(legacyInteraction.receivedAtTimestamp),
timestampMs: Int64(legacyInteraction.timestamp),
receivedAtTimestampMs: Int64(legacyInteraction.receivedAtTimestamp),
wasRead: wasRead,
expiresInSeconds: expiresInSeconds.map { TimeInterval($0) },
expiresStartedAtMs: expiresStartedAtMs.map { Double($0) },
linkPreviewUrl: linkPreview?.urlString, // Only a soft link so save to set
openGroupServerMessageId: openGroupServerMessageId.map { Int64($0) },
openGroupWhisperMods: false, // TODO: This
openGroupWhisperTo: nil // TODO: This
openGroupWhisperMods: false, // TODO: This in SOGSV4
openGroupWhisperTo: nil // TODO: This in SOGSV4
).inserted(db)
guard let interactionId: Int64 = interaction.id else {
// TODO: Is it possible the old database has duplicates which could hit this case?
SNLog("[Migration Error] Failed to insert interaction")
throw GRDBStorageError.migrationFailed
}
// Store the interactionId in the lookup map to simplify job creation later
let legacyIdentifier: String = identifier(
for: legacyInteraction.uniqueThreadId,
sentTimestamp: legacyInteraction.timestamp,
recipients: ((legacyInteraction as? TSOutgoingMessage)?.recipientIds() ?? [])
)
legacyInteractionToIdMap[legacyInteraction.uniqueId ?? ""] = interactionId
legacyInteractionIdentifierToIdMap[legacyIdentifier] = interactionId
// Handle the recipient states
// Note: Inserting an Interaction into the database will automatically create a 'RecipientState'
// for outgoing messages
try recipientStateMap?.forEach { recipientId, legacyState in
try RecipientState(
interactionId: interactionId,
@ -493,26 +648,19 @@ enum _002_YDBToGRDBMigration: Migration {
@unknown default: throw GRDBStorageError.migrationFailed
}
}(),
readTimestampMs: legacyState.readTimestamp?.doubleValue
).insert(db)
readTimestampMs: legacyState.readTimestamp?.int64Value,
mostRecentFailureText: (legacyState.state == .failed ?
mostRecentFailureText :
nil
)
).save(db)
}
// Handle any quote
if let quotedMessage: TSQuotedMessage = quotedMessage {
try Quote(
interactionId: interactionId,
authorId: quotedMessage.authorId,
timestampMs: Double(quotedMessage.timestamp),
body: quotedMessage.body
).insert(db)
// Ensure the quote thumbnail works properly
// Note: Quote attachments are now attached directly to the interaction
attachmentIds = attachmentIds.appending(
contentsOf: quotedMessage.quotedAttachments.compactMap { attachmentInfo in
let quoteAttachmentId: String? = quotedMessage.quotedAttachments
.compactMap { attachmentInfo in
if let attachmentId: String = attachmentInfo.attachmentId {
return attachmentId
}
@ -522,7 +670,21 @@ enum _002_YDBToGRDBMigration: Migration {
// TODO: Looks like some of these might be busted???
return attachmentInfo.thumbnailAttachmentStreamId
}
)
.first { attachments[$0] != nil }
guard quotedMessage.quotedAttachments.isEmpty || quoteAttachmentId != nil else {
// TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded?
SNLog("[Migration Error] Missing quote attachment")
throw GRDBStorageError.migrationFailed
}
try Quote(
interactionId: interactionId,
authorId: quotedMessage.authorId,
timestampMs: Int64(quotedMessage.timestamp),
body: quotedMessage.body,
attachmentId: try attachmentId(db, for: quoteAttachmentId, attachments: attachments)
).insert(db)
}
// Handle any LinkPreview
@ -531,56 +693,370 @@ enum _002_YDBToGRDBMigration: Migration {
// Note: The `legacyInteraction.timestamp` value is in milliseconds
let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(legacyInteraction.timestamp))
guard linkPreview.imageAttachmentId == nil || attachments[linkPreview.imageAttachmentId ?? ""] != nil else {
// TODO: Is it possible to hit this case if a quoted attachment hasn't been downloaded?
SNLog("[Migration Error] Missing link preview attachment")
throw GRDBStorageError.migrationFailed
}
// Note: It's possible for there to be duplicate values here so we use 'save'
// instead of insert (ie. upsert)
try LinkPreview(
url: urlString,
timestamp: timestamp,
variant: linkPreviewVariant,
title: linkPreview.title
title: linkPreview.title,
attachmentId: try attachmentId(db, for: linkPreview.imageAttachmentId, attachments: attachments)
).save(db)
// Note: LinkPreview attachments are now attached directly to the interaction
attachmentIds = attachmentIds.appending(linkPreview.imageAttachmentId)
}
// Handle any attachments
try attachmentIds.forEach { attachmentId in
guard let attachment: TSAttachment = attachments[attachmentId] else {
SNLog("[Migration Error] Unsupported interaction type")
print("ASD \(attachmentIds)")
try attachmentIds.forEach { legacyAttachmentId in
guard let attachmentId: String = try attachmentId(db, for: legacyAttachmentId, interactionVariant: variant, attachments: attachments) else {
// TODO: Is it possible to hit this case if an interaction hasn't been viewed?
SNLog("[Migration Error] Missing interaction attachment")
throw GRDBStorageError.migrationFailed
}
let size: CGSize = {
switch attachment {
case let stream as TSAttachmentStream: return stream.calculateImageSize()
case let pointer as TSAttachmentPointer: return pointer.mediaSize
default: return CGSize.zero
}
}()
try Attachment(
try InteractionAttachment(
interactionId: interactionId,
serverId: "\(attachment.serverId)",
variant: (attachment.isVoiceMessage ? .voiceMessage : .standard),
state: .pending, // TODO: This
contentType: attachment.contentType,
byteCount: UInt(attachment.byteCount),
creationTimestamp: (attachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970,
sourceFilename: attachment.sourceFilename,
downloadUrl: attachment.downloadURL,
width: (size == .zero ? nil : UInt(size.width)),
height: (size == .zero ? nil : UInt(size.height)),
encryptionKey: attachment.encryptionKey,
digest: (attachment as? TSAttachmentStream)?.digest,
caption: attachment.caption
attachmentId: attachmentId
).insert(db)
}
}
}
}
// Clear out processed data (give the memory a change to be freed)
contacts = []
contactThreadIds = []
threads = []
disappearingMessagesConfiguration = [:]
closedGroupKeys = [:]
closedGroupName = [:]
closedGroupFormation = [:]
closedGroupModel = [:]
closedGroupZombieMemberIds = [:]
openGroupInfo = [:]
openGroupUserCount = [:]
openGroupImage = [:]
openGroupLastMessageServerId = [:]
openGroupLastDeletionServerId = [:]
interactions = [:]
attachments = [:]
// MARK: - Process Legacy Jobs
var notifyPushServerJobs: Set<Legacy.NotifyPNServerJob> = []
var messageReceiveJobs: Set<Legacy.MessageReceiveJob> = []
var messageSendJobs: Set<Legacy.MessageSendJob> = []
var attachmentUploadJobs: Set<Legacy.AttachmentUploadJob> = []
var attachmentDownloadJobs: Set<Legacy.AttachmentDownloadJob> = []
Storage.read { transaction in
transaction.enumerateRows(inCollection: Legacy.notifyPushServerJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.NotifyPNServerJob else { return }
notifyPushServerJobs.insert(job)
}
transaction.enumerateRows(inCollection: Legacy.messageReceiveJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.MessageReceiveJob else { return }
messageReceiveJobs.insert(job)
}
transaction.enumerateRows(inCollection: Legacy.messageSendJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.MessageSendJob else { return }
messageSendJobs.insert(job)
}
transaction.enumerateRows(inCollection: Legacy.attachmentUploadJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.AttachmentUploadJob else { return }
attachmentUploadJobs.insert(job)
}
transaction.enumerateRows(inCollection: Legacy.attachmentDownloadJobCollection) { _, object, _, _ in
guard let job = object as? Legacy.AttachmentDownloadJob else { return }
attachmentDownloadJobs.insert(job)
}
}
// MARK: - Insert Jobs
// MARK: - --notifyPushServer
try autoreleasepool {
try notifyPushServerJobs.forEach { legacyJob in
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .notifyPushServer,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
SnodeMessage(
recipient: legacyJob.message.recipient,
data: legacyJob.message.data.description, // TODO: Test this (looks like it should be fine)
ttl: legacyJob.message.ttl,
timestampMs: legacyJob.message.timestamp
)
),
encoding: .utf8
)
)?.inserted(db)
}
}
// MARK: - --messageReceive
try autoreleasepool {
try messageReceiveJobs.forEach { legacyJob in
// We haven't supported OpenGroup messageReceive jobs for a long time so if
// we see any then just ignore them
if legacyJob.openGroupID != nil && legacyJob.openGroupMessageServerID != nil {
return
}
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .messageReceive,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
MessageReceiveJob.Details(
data: legacyJob.data,
serverHash: legacyJob.serverHash,
isBackgroundPoll: legacyJob.isBackgroundPoll
)
),
encoding: .utf8
)
)?.inserted(db)
}
}
// MARK: - --messageSend
var messageSendJobIdMap: [String: Int64] = [:]
try autoreleasepool {
try messageSendJobs.forEach { legacyJob in
let legacyIdentifier: String = identifier(
for: (legacyJob.message.threadID ?? ""),
sentTimestamp: (legacyJob.message.sentTimestamp ?? 0),
recipients: (legacyJob.message.recipient.map { [$0] } ?? []),
destination: legacyJob.destination
)
// Fetch the interaction this job should be associated with
let job: Job? = try Job(
failureCount: legacyJob.failureCount,
variant: .messageSend,
behaviour: .runOnce,
nextRunTimestamp: 0,
threadId: legacyThreadIdToIdMap[legacyJob.message.threadID ?? ""],
details: MessageSendJob.Details(
// Note: There are some cases where there isn't actually a link between the 'MessageSendJob' and
// it's associated interaction (ie. any ControlMessage), in these cases the 'interactionId' value
// will be nil
interactionId: legacyInteractionIdentifierToIdMap[legacyIdentifier],
destination: legacyJob.destination,
message: legacyJob.message
)
)?.inserted(db)
if let oldId: String = legacyJob.id, let newId: Int64 = job?.id {
messageSendJobIdMap[oldId] = newId
}
}
}
// MARK: - --attachmentUpload
try autoreleasepool {
try attachmentUploadJobs.forEach { legacyJob in
guard let sendJobId: Int64 = messageSendJobIdMap[legacyJob.messageSendJobID] else {
SNLog("[Migration Error] attachmentUpload job missing associated MessageSendJob")
throw GRDBStorageError.migrationFailed
}
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .attachmentUpload,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
AttachmentUploadJob.Details(
threadId: legacyJob.threadID,
attachmentId: legacyJob.attachmentID,
messageSendJobId: sendJobId
)
),
encoding: .utf8
)
)?.inserted(db)
}
}
// MARK: - --attachmentDownload
try autoreleasepool {
try attachmentDownloadJobs.forEach { legacyJob in
guard let interactionId: Int64 = legacyInteractionToIdMap[legacyJob.tsMessageID] else {
SNLog("[Migration Error] attachmentDownload job unable to find interaction")
throw GRDBStorageError.migrationFailed
}
_ = try Job(
failureCount: legacyJob.failureCount,
variant: .attachmentDownload,
behaviour: .runOnce,
nextRunTimestamp: 0,
details: String(
data: try JSONEncoder().encode(
AttachmentDownloadJob.Details(
threadId: legacyJob.threadID,
attachmentId: legacyJob.attachmentID
)
),
encoding: .utf8
)
)?.inserted(db)
}
}
// MARK: - --sendReadReceipts
try autoreleasepool {
try outgoingReadReceiptsTimestampsMs.forEach { threadId, timestampsMs in
_ = try Job(
variant: .sendReadReceipts,
behaviour: .recurring,
threadId: threadId,
details: SendReadReceiptsJob.Details(
destination: .contact(publicKey: threadId),
timestampMsValues: timestampsMs
)
)?.inserted(db)
}
}
// MARK: - Process Preferences
var legacyPreferences: [String: Any] = [:]
Storage.read { transaction in
transaction.enumerateKeysAndObjects(inCollection: Legacy.preferencesCollection) { key, object, _ in
legacyPreferences[key] = object
}
// Note: The 'int(forKey:inCollection:)' defaults to `0` which is an incorrect value for the notification
// sound so catch it and default
let globalNotificationSoundValue: Int32 = transaction.int(
forKey: Legacy.soundsGlobalNotificationKey,
inCollection: Legacy.soundsStorageNotificationCollection
)
legacyPreferences[Legacy.soundsGlobalNotificationKey] = (globalNotificationSoundValue > 0 ?
Int(globalNotificationSoundValue) :
Preferences.Sound.defaultNotificationSound.rawValue
)
legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] = (transaction.bool(
forKey: Legacy.readReceiptManagerAreReadReceiptsEnabled,
inCollection: Legacy.readReceiptManagerCollection,
defaultValue: false
) ? 1 : 0)
legacyPreferences[Legacy.typingIndicatorsEnabledKey] = (transaction.bool(
forKey: Legacy.typingIndicatorsEnabledKey,
inCollection: Legacy.typingIndicatorsCollection,
defaultValue: false
) ? 1 : 0)
}
db[.preferencesNotificationPreviewType] = Preferences.NotificationPreviewType(rawValue: legacyPreferences[Legacy.preferencesKeyNotificationPreviewType] as? Int ?? -1)
.defaulting(to: .nameAndPreview)
db[.defaultNotificationSound] = Preferences.Sound(rawValue: legacyPreferences[Legacy.soundsGlobalNotificationKey] as? Int ?? -1)
.defaulting(to: Preferences.Sound.defaultNotificationSound)
if let lastPushToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedPushToken] as? String {
db[.lastRecordedPushToken] = lastPushToken
}
if let lastVoipToken: String = legacyPreferences[Legacy.preferencesKeyLastRecordedVoipToken] as? String {
db[.lastRecordedVoipToken] = lastVoipToken
}
// Note: The 'preferencesKeyScreenSecurityDisabled' value previously controlled whether the setting
// was disabled, this has been inverted to 'preferencesAppSwitcherPreviewEnabled' so it can default
// to 'false' (as most Bool values do)
db[.preferencesAppSwitcherPreviewEnabled] = (legacyPreferences[Legacy.preferencesKeyScreenSecurityDisabled] as? Bool == false)
db[.areReadReceiptsEnabled] = (legacyPreferences[Legacy.readReceiptManagerAreReadReceiptsEnabled] as? Bool == true)
db[.typingIndicatorsEnabled] = (legacyPreferences[Legacy.typingIndicatorsEnabledKey] as? Bool == true)
db[.hasHiddenMessageRequests] = CurrentAppContext().appUserDefaults()
.bool(forKey: Legacy.userDefaultsHasHiddenMessageRequests)
print("RAWR [\(Date().timeIntervalSince1970)] - Process thread inserts - End")
print("RAWR Done!!!")
}
// MARK: - Convenience
private static func attachmentId(_ db: Database, for legacyAttachmentId: String?, interactionVariant: Interaction.Variant? = nil, attachments: [String: TSAttachment]) throws -> String? {
guard let legacyAttachmentId: String = legacyAttachmentId else { return nil }
guard let legacyAttachment: TSAttachment = attachments[legacyAttachmentId] else {
SNLog("[Migration Error] Missing attachment")
throw GRDBStorageError.migrationFailed
}
let state: Attachment.State = {
switch legacyAttachment {
case let stream as TSAttachmentStream: // Outgoing or already downloaded
switch interactionVariant {
case .standardOutgoing: return (stream.isUploaded ? .uploaded : .pending)
default: return .downloaded
}
// All other cases can just be set to 'pending'
default: return .pending
}
}()
let size: CGSize = {
switch legacyAttachment {
case let stream as TSAttachmentStream: return stream.calculateImageSize()
case let pointer as TSAttachmentPointer: return pointer.mediaSize
default: return CGSize.zero
}
}()
let attachment: Attachment = try Attachment(
serverId: "\(legacyAttachment.serverId)",
variant: (legacyAttachment.isVoiceMessage ? .voiceMessage : .standard),
state: state,
contentType: legacyAttachment.contentType,
byteCount: UInt(legacyAttachment.byteCount),
creationTimestamp: (legacyAttachment as? TSAttachmentStream)?.creationTimestamp.timeIntervalSince1970,
sourceFilename: legacyAttachment.sourceFilename,
downloadUrl: legacyAttachment.downloadURL,
width: (size == .zero ? nil : UInt(size.width)),
height: (size == .zero ? nil : UInt(size.height)),
encryptionKey: legacyAttachment.encryptionKey,
digest: (legacyAttachment as? TSAttachmentStream)?.digest,
caption: legacyAttachment.caption
).inserted(db)
return attachment.id
}
}

View File

@ -4,14 +4,15 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct Attachment: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "attachment" }
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
internal static let interactionAttachments = belongsTo(InteractionAttachment.self)
fileprivate static let quote = belongsTo(Quote.self)
fileprivate static let linkPreview = belongsTo(LinkPreview.self)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case interactionId
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
case id
case serverId
case variant
case state
@ -41,8 +42,8 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco
case failed
}
/// The id for the Interaction this attachment belongs to
public let interactionId: Int64?
/// A unique identifier for the attachment
public let id: String = UUID().uuidString
/// The id for the attachment returned by the server
///
@ -95,9 +96,438 @@ public struct Attachment: Codable, FetchableRecord, PersistableRecord, TableReco
/// Caption for the attachment
public let caption: String?
// MARK: - Relationships
// MARK: - Initialization
public var interaction: QueryInterfaceRequest<Interaction> {
request(for: Attachment.interaction)
public init(
serverId: String? = nil,
variant: Variant,
state: State = .pending,
contentType: String,
byteCount: UInt,
creationTimestamp: TimeInterval? = nil,
sourceFilename: String? = nil,
downloadUrl: String? = nil,
width: UInt? = nil,
height: UInt? = nil,
encryptionKey: Data? = nil,
digest: Data? = nil,
caption: String? = nil
) {
self.serverId = serverId
self.variant = variant
self.state = state
self.contentType = contentType
self.byteCount = byteCount
self.creationTimestamp = creationTimestamp
self.sourceFilename = sourceFilename
self.downloadUrl = downloadUrl
self.width = width
self.height = height
self.encryptionKey = encryptionKey
self.digest = digest
self.caption = caption
}
public init?(
variant: Variant = .standard,
contentType: String,
dataSource: DataSource
) {
guard
let originalFilePath: String = Attachment.originalFilePath(id: self.id, mimeType: contentType, sourceFilename: nil)
else {
return nil
}
guard dataSource.write(toPath: originalFilePath) else { return nil }
let imageSize: CGSize? = Attachment.imageSize(
contentType: contentType,
originalFilePath: originalFilePath
)
self.serverId = nil
self.variant = variant
self.state = .pending
self.contentType = contentType
self.byteCount = dataSource.dataLength()
self.creationTimestamp = nil
self.sourceFilename = nil
self.downloadUrl = nil
self.width = imageSize.map { UInt(floor($0.width)) }
self.height = imageSize.map { UInt(floor($0.height)) }
self.encryptionKey = nil
self.digest = nil
self.caption = nil
}
}
// MARK: - CustomStringConvertible
extension Attachment: CustomStringConvertible {
public var description: String {
if MIMETypeUtil.isAudio(contentType) {
// a missing filename is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments.
if variant == .voiceMessage || self.sourceFilename == nil || (self.sourceFilename?.count ?? 0) == 0 {
return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())"
}
}
return "\("ATTACHMENT".localized()) \(emojiForMimeType)"
}
}
// MARK: - Mutation
public extension Attachment {
func with(
serverId: String? = nil,
state: State? = nil,
downloadUrl: String? = nil,
encryptionKey: Data? = nil,
digest: Data? = nil
) -> Attachment {
return Attachment(
serverId: (serverId ?? self.serverId),
variant: variant,
state: (state ?? self.state),
contentType: contentType,
byteCount: byteCount,
creationTimestamp: creationTimestamp,
sourceFilename: sourceFilename,
downloadUrl: (downloadUrl ?? self.downloadUrl),
width: width,
height: height,
encryptionKey: (encryptionKey ?? self.encryptionKey),
digest: (digest ?? self.digest),
caption: self.caption
)
}
}
// MARK: - Protobuf
public extension Attachment {
init(proto: SNProtoAttachmentPointer) {
func inferContentType(from filename: String?) -> String {
guard
let fileName: String = filename,
let fileExtension: String = URL(string: fileName)?.pathExtension
else { return OWSMimeTypeApplicationOctetStream }
return (MIMETypeUtil.mimeType(forFileExtension: fileExtension) ?? OWSMimeTypeApplicationOctetStream)
}
self.serverId = nil
self.variant = {
let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags
.voiceMessage
.rawValue
guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else {
return .standard
}
return .voiceMessage
}()
self.state = .pending
self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName))
self.byteCount = UInt(proto.size)
self.creationTimestamp = nil
self.sourceFilename = proto.fileName
self.downloadUrl = proto.url
self.width = (proto.hasWidth && proto.width > 0 ? UInt(proto.width) : nil)
self.height = (proto.hasHeight && proto.height > 0 ? UInt(proto.height) : nil)
self.encryptionKey = proto.key
self.digest = proto.digest
self.caption = (proto.hasCaption ? proto.caption : nil)
}
func buildProto() -> SNProtoAttachmentPointer? {
guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil }
let builder = SNProtoAttachmentPointer.builder(id: serverId)
builder.setContentType(contentType)
if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty {
builder.setFileName(sourceFilename)
}
if let caption: String = self.caption, !caption.isEmpty {
builder.setCaption(caption)
}
builder.setSize(UInt32(byteCount))
builder.setFlags(variant == .voiceMessage ?
UInt32(SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags.voiceMessage.rawValue) :
0
)
if let encryptionKey: Data = encryptionKey, let digest: Data = digest {
builder.setKey(encryptionKey)
builder.setDigest(digest)
}
if
let width: UInt = self.width,
let height: UInt = self.height,
width > 0,
width < Int.max,
height > 0,
height < Int.max
{
builder.setWidth(UInt32(width))
builder.setHeight(UInt32(height))
}
if let downloadUrl: String = self.downloadUrl {
builder.setUrl(downloadUrl)
}
do {
return try builder.build()
}
catch {
SNLog("Couldn't construct attachment proto from: \(self).")
return nil
}
}
}
// MARK: - GRDB Interactions
public extension Attachment {
static func fetchAllPendingAttachments(_ db: Database, for threadId: String) throws -> [Attachment] {
return try Attachment
.select(Attachment.Columns.allCases + [Interaction.Columns.id])
.filter(Columns.variant == Variant.standard)
.filter(Columns.state == State.pending)
.joining(
optional: Attachment.interactionAttachments
.filter(Interaction.Columns.threadId == threadId)
)
.joining(
optional: Attachment.quote
.joining(
required: Quote.interaction
.filter(Interaction.Columns.threadId == threadId)
)
)//tmp.authorId
.joining(
optional: Attachment.linkPreview
.joining(
required: LinkPreview.interactions
.filter(Interaction.Columns.threadId == threadId)
)
)
.order(Interaction.Columns.id.desc) // Newest attachments first
.fetchAll(db)
}
}
// MARK: - Convenience - Static
public extension Attachment {
private static let thumbnailDimensionSmall: UInt = 200
private static let thumbnailDimensionMedium: UInt = 450
/// This size is large enough to render full screen
private static var thumbnailDimensionsLarge: CGFloat = {
let screenSizePoints: CGSize = UIScreen.main.bounds.size
let minZoomFactor: CGFloat = 2 // TODO: Should this be screen scale?
return (max(screenSizePoints.width, screenSizePoints.height) * minZoomFactor)
}()
private static var sharedDataAttachmentsDirPath: String = {
OWSFileSystem.appSharedDataDirectoryPath().appending("/Attachments")
}()
private static var attachmentsFolder: String = {
let attachmentsFolder: String = sharedDataAttachmentsDirPath
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
return attachmentsFolder
}()
private static var thumbnailsFolder: String = {
let attachmentsFolder: String = sharedDataAttachmentsDirPath
OWSFileSystem.ensureDirectoryExists(attachmentsFolder)
return attachmentsFolder
}()
private static func originalFilePath(id: String, mimeType: String, sourceFilename: String?) -> String? {
let maybeFilePath: String? = MIMETypeUtil.filePath(
forAttachment: id, // TODO: Can we avoid this???
ofMIMEType: mimeType,
sourceFilename: sourceFilename,
inFolder: Attachment.attachmentsFolder
)
guard let filePath: String = maybeFilePath else { return nil }
guard filePath.hasPrefix(Attachment.attachmentsFolder) else { return nil }
let localRelativeFilePath: String = filePath.substring(from: Attachment.attachmentsFolder.count)
guard !localRelativeFilePath.isEmpty else { return nil }
return localRelativeFilePath
}
static func imageSize(contentType: String, originalFilePath: String) -> CGSize? {
let isVideo: Bool = MIMETypeUtil.isVideo(contentType)
let isImage: Bool = MIMETypeUtil.isImage(contentType)
let isAnimated: Bool = MIMETypeUtil.isAnimated(contentType)
guard isVideo || isImage || isAnimated else { return nil }
if isVideo {
guard OWSMediaUtils.isValidVideo(path: originalFilePath) else { return nil }
return Attachment.videoStillImage(filePath: originalFilePath)?.size
}
return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType)
}
static func videoStillImage(filePath: String) -> UIImage? {
return try? OWSMediaUtils.thumbnail(
forVideoAtPath: filePath,
maxDimension: Attachment.thumbnailDimensionsLarge
)
}
}
// MARK: - Convenience
extension Attachment {
var originalFilePath: String? {
return Attachment.originalFilePath(
id: self.id,
mimeType: self.contentType,
sourceFilename: self.sourceFilename
)
}
var localRelativeFilePath: String? {
return originalFilePath?.substring(from: Attachment.attachmentsFolder.count)
}
var thumbnailsDirPath: String {
// Thumbnails are written to the caches directory, so that iOS can
// remove them if necessary
return "\(OWSFileSystem.cachesDirectoryPath())/\(id)-thumbnails"
}
var originalImage: UIImage? {
guard let originalFilePath: String = originalFilePath else { return nil }
if isVideo {
return Attachment.videoStillImage(filePath: originalFilePath)
}
guard isImage || isAnimated else { return nil }
guard NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) else {
return nil
}
return UIImage(contentsOfFile: originalFilePath)
}
var emojiForMimeType: String {
if MIMETypeUtil.isImage(contentType) {
return "📷"
}
else if MIMETypeUtil.isVideo(contentType) {
return "🎥"
}
else if MIMETypeUtil.isAudio(contentType) {
return "🎧"
}
else if MIMETypeUtil.isAnimated(contentType) {
return "🎡"
}
return "📎"
}
var isImage: Bool { MIMETypeUtil.isImage(contentType) }
var isVideo: Bool { MIMETypeUtil.isVideo(contentType) }
var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) }
func readDataFromFile() throws -> Data? {
guard let filePath: String = Attachment.originalFilePath(id: self.id, mimeType: self.contentType, sourceFilename: self.sourceFilename) else {
return nil
}
return try Data(contentsOf: URL(fileURLWithPath: filePath))
}
public func thumbnailPath(for dimensions: UInt) -> String {
return "\(thumbnailsDirPath)/thumbnail-\(dimensions).jpg"
}
private func loadThumbnail(with dimensions: UInt, success: @escaping (UIImage) -> (), failure: @escaping () -> ()) {
guard let width: UInt = self.width, let height: UInt = self.height, width > 1, height > 1 else {
failure()
return
}
// There's no point in generating a thumbnail if the original is smaller than the
// thumbnail size
if width < dimensions || height < dimensions {
guard let image: UIImage = originalImage else {
failure()
return
}
success(image)
return
}
let thumbnailPath = thumbnailPath(for: dimensions)
if FileManager.default.fileExists(atPath: thumbnailPath) {
guard let image: UIImage = UIImage(contentsOfFile: thumbnailPath) else {
failure()
return
}
success(image)
return
}
OWSThumbnailService.shared.ensureThumbnail(
for: self,
dimensions: dimensions,
success: { loadedThumbnail in success(loadedThumbnail.image) },
failure: { _ in failure() }
)
}
func thumbnailImageSmallSync() -> UIImage? {
guard isVideo || isImage || isAnimated else { return nil }
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)
var image: UIImage?
loadThumbnail(
with: Attachment.thumbnailDimensionSmall,
success: { loadedImage in
image = loadedImage
semaphore.signal()
},
failure: { semaphore.signal() }
)
// Wait up to 5 seconds for the thumbnail to be loaded
_ = semaphore.wait(timeout: .now() + .seconds(5))
return image
}
public func cloneAsThumbnail() -> Attachment {
fatalError("TODO: Add this back")
}
}

View File

@ -41,22 +41,26 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe
request(for: ClosedGroup.keyPairs)
}
public var memberIds: QueryInterfaceRequest<GroupMember> {
public var allMembers: QueryInterfaceRequest<GroupMember> {
request(for: ClosedGroup.members)
}
public var members: QueryInterfaceRequest<GroupMember> {
request(for: ClosedGroup.members)
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
}
public var zombieIds: QueryInterfaceRequest<GroupMember> {
public var zombies: QueryInterfaceRequest<GroupMember> {
request(for: ClosedGroup.members)
.filter(GroupMember.Columns.role == GroupMember.Role.zombie)
}
public var moderatorIds: QueryInterfaceRequest<GroupMember> {
public var moderators: QueryInterfaceRequest<GroupMember> {
request(for: ClosedGroup.members)
.filter(GroupMember.Columns.role == GroupMember.Role.moderator)
}
public var adminIds: QueryInterfaceRequest<GroupMember> {
public var admins: QueryInterfaceRequest<GroupMember> {
request(for: ClosedGroup.members)
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
}
@ -71,3 +75,25 @@ public struct ClosedGroup: Codable, Identifiable, FetchableRecord, PersistableRe
return try performDelete(db)
}
}
// MARK: - Mutation
public extension ClosedGroup {
func with(name: String) -> ClosedGroup {
return ClosedGroup(
threadId: threadId,
name: name,
formationTimestamp: formationTimestamp
)
}
}
// MARK: - GRDB Interactions
public extension ClosedGroup {
func fetchLatestKeyPair(_ db: Database) throws -> ClosedGroupKeyPair? {
return try keyPairs
.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc)
.fetchOne(db)
}
}

View File

@ -4,7 +4,7 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct ClosedGroupKeyPair: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "closedGroupKeyPair" }
internal static let closedGroupForeignKey = ForeignKey(
[Columns.publicKey],
@ -19,8 +19,6 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis
case receivedTimestamp
}
public var id: String { publicKey }
public let publicKey: String
public let secretKey: Data
public let receivedTimestamp: TimeInterval
@ -30,4 +28,27 @@ public struct ClosedGroupKeyPair: Codable, Identifiable, FetchableRecord, Persis
public var closedGroup: QueryInterfaceRequest<ClosedGroup> {
request(for: ClosedGroupKeyPair.closedGroup)
}
// MARK: - Initialization
public init(
publicKey: String,
secretKey: Data,
receivedTimestamp: TimeInterval
) {
self.publicKey = publicKey
self.secretKey = secretKey
self.receivedTimestamp = receivedTimestamp
}
}
// MARK: - GRDB Interactions
public extension ClosedGroupKeyPair {
static func fetchLatestKeyPair(_ db: Database, publicKey: String) throws -> ClosedGroupKeyPair? {
return try ClosedGroupKeyPair
.filter(Columns.publicKey == publicKey)
.order(Columns.receivedTimestamp.desc)
.fetchOne(db)
}
}

View File

@ -6,6 +6,7 @@ import SessionUtilitiesKit
public struct Contact: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "contact" }
internal static let threadForeignKey = ForeignKey([Columns.id], to: [SessionThread.Columns.id])
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {

View File

@ -0,0 +1,23 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "controlMessageProcessRecord" }
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case threadId
case sentTimestampMs
case serverHash
case openGroupMessageServerId
}
public let threadId: String
public let sentTimestampMs: Int64
public let serverHash: String
public let openGroupMessageServerId: Int64
}

View File

@ -29,18 +29,65 @@ public struct DisappearingMessagesConfiguration: Codable, Identifiable, Fetchabl
}
}
// MARK: - Mutation
public extension DisappearingMessagesConfiguration {
static let defaultDuration: TimeInterval = (24 * 60 * 60)
static func defaultWith(_ threadId: String) -> DisappearingMessagesConfiguration {
return DisappearingMessagesConfiguration(
threadId: threadId,
isEnabled: false,
durationSeconds: defaultDuration
)
}
func with(
isEnabled: Bool? = nil,
durationSeconds: TimeInterval? = nil
) -> DisappearingMessagesConfiguration {
return DisappearingMessagesConfiguration(
threadId: threadId,
isEnabled: (isEnabled ?? self.isEnabled),
durationSeconds: (durationSeconds ?? self.durationSeconds)
)
}
}
// MARK: - Convenience
extension DisappearingMessagesConfiguration {
public var durationIndex: Int {
public extension DisappearingMessagesConfiguration {
var durationIndex: Int {
return DisappearingMessagesConfiguration.validDurationsSeconds
.firstIndex(of: durationSeconds)
.defaulting(to: 0)
}
public var durationString: String {
var durationString: String {
NSString.formatDurationSeconds(UInt32(durationSeconds), useShortFormat: false)
}
func infoUpdateMessage(with senderName: String?) -> String {
guard let senderName: String = senderName else {
// Changed by localNumber on this device or via synced transcript
guard isEnabled, durationSeconds > 0 else { return "YOU_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized() }
return String(
format: "YOU_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(),
NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false)
)
}
guard isEnabled, durationSeconds > 0 else {
return String(format: "OTHER_DISABLED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(), senderName)
}
return String(
format: "OTHER_UPDATED_DISAPPEARING_MESSAGES_CONFIGURATION".localized(),
NSString.formatDurationSeconds(UInt32(floor(durationSeconds)), useShortFormat: false),
senderName
)
}
}
// MARK: - UI Constraints

View File

@ -44,4 +44,16 @@ public struct GroupMember: Codable, FetchableRecord, PersistableRecord, TableRec
public var profile: QueryInterfaceRequest<Profile> {
request(for: GroupMember.profile)
}
// MARK: - Initialization
public init(
groupId: String,
profileId: String,
role: Role
) {
self.groupId = groupId
self.profileId = profileId
self.role = role
}
}

View File

@ -4,7 +4,7 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "interaction" }
internal static let threadForeignKey = ForeignKey([Columns.threadId], to: [SessionThread.Columns.id])
internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id])
@ -12,11 +12,19 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
[Columns.linkPreviewUrl],
to: [LinkPreview.Columns.url]
)
private static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
internal static let thread = belongsTo(SessionThread.self, using: threadForeignKey)
private static let profile = hasOne(Profile.self, using: profileForeignKey)
private static let attachments = hasMany(Attachment.self, using: Attachment.interactionForeignKey)
private static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey)
private static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey)
internal static let interactionAttachments = hasMany(
InteractionAttachment.self,
using: InteractionAttachment.interactionForeignKey
)
internal static let attachments = hasMany(
Attachment.self,
through: interactionAttachments,
using: InteractionAttachment.attachment
)
public static let quote = hasOne(Quote.self, using: Quote.interactionForeignKey)
internal static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey)
private static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey)
public typealias Columns = CodingKeys
@ -30,6 +38,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
case body
case timestampMs
case receivedAtTimestampMs
case wasRead
case expiresInSeconds
case expiresStartedAtMs
@ -71,9 +80,9 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
public let serverHash: String?
/// The id of the thread that this interaction belongs to (used to expose the `thread` variable)
private let threadId: String
public let threadId: String
/// The id of the user who sent the message, also used to expose the `profile` variable)
/// The id of the user who sent the interaction, also used to expose the `profile` variable)
public let authorId: String
/// The type of interaction
@ -83,18 +92,28 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
public let body: String?
/// When the interaction was created in milliseconds since epoch
public let timestampMs: Double
///
/// **Note:** This value will be `0` if it hasn't been set yet
public let timestampMs: Int64
/// When the interaction was received in milliseconds since epoch
public let receivedAtTimestampMs: Double
///
/// **Note:** This value will be `0` if it hasn't been set yet
public let receivedAtTimestampMs: Int64
/// A flag indicating whether the interaction has been read (this is a flag rather than a timestamp because
/// we couldnt know if a read timestamp is accurate)
///
/// **Note:** This flag is not applicable to standardOutgoing or standardIncomingDeleted interactions
public let wasRead: Bool
/// The number of seconds until this message should expire
public fileprivate(set) var expiresInSeconds: TimeInterval? = nil
public let expiresInSeconds: TimeInterval?
/// The timestamp in milliseconds since 1970 at which this messages expiration timer started counting
/// down (this is stored in order to allow the `expiresInSeconds` value to be updated before a
/// message has expired)
public fileprivate(set) var expiresStartedAtMs: Double? = nil
public let expiresStartedAtMs: Double?
/// This value is the url for the link preview for this interaction
///
@ -104,7 +123,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
// Open Group specific properties
/// The `openGroupServerMessageId` value will only be set for messages from SOGS
public fileprivate(set) var openGroupServerMessageId: Int64? = nil
public let openGroupServerMessageId: Int64?
/// This flag indicates whether this interaction is a whisper to the mods of an Open Group
public let openGroupWhisperMods: Bool
@ -122,6 +141,12 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
request(for: Interaction.profile)
}
/// Depending on the data associated to this interaction this array will represent different things, these
/// cases are mutually exclusive:
///
/// **Quote:** The thumbnails associated to the `Quote`
/// **LinkPreview:** The thumbnails associated to the `LinkPreview`
/// **Other:** The files directly attached to the interaction
public var attachments: QueryInterfaceRequest<Attachment> {
request(for: Interaction.attachments)
}
@ -153,15 +178,16 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
// MARK: - Initialization
// TODO: Do we actually want these values to have defaults? (check how messages are getting created - convenience constructors??)
init(
internal init(
id: Int64? = nil,
serverHash: String?,
threadId: String,
authorId: String,
variant: Variant,
body: String?,
timestampMs: Double,
receivedAtTimestampMs: Double,
timestampMs: Int64,
receivedAtTimestampMs: Int64,
wasRead: Bool,
expiresInSeconds: TimeInterval?,
expiresStartedAtMs: Double?,
linkPreviewUrl: String?,
@ -169,6 +195,7 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
openGroupWhisperMods: Bool,
openGroupWhisperTo: String?
) {
self.id = id
self.serverHash = serverHash
self.threadId = threadId
self.authorId = authorId
@ -176,6 +203,45 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
self.body = body
self.timestampMs = timestampMs
self.receivedAtTimestampMs = receivedAtTimestampMs
self.wasRead = wasRead
self.expiresInSeconds = expiresInSeconds
self.expiresStartedAtMs = expiresStartedAtMs
self.linkPreviewUrl = linkPreviewUrl
self.openGroupServerMessageId = openGroupServerMessageId
self.openGroupWhisperMods = openGroupWhisperMods
self.openGroupWhisperTo = openGroupWhisperTo
}
public init(
serverHash: String? = nil,
threadId: String,
authorId: String,
variant: Variant,
body: String? = nil,
timestampMs: Int64 = 0,
wasRead: Bool = false,
expiresInSeconds: TimeInterval? = nil,
expiresStartedAtMs: Double? = nil,
linkPreviewUrl: String? = nil,
openGroupServerMessageId: Int64? = nil,
openGroupWhisperMods: Bool = false,
openGroupWhisperTo: String? = nil
) throws {
self.serverHash = serverHash
self.threadId = threadId
self.authorId = authorId
self.variant = variant
self.body = body
self.timestampMs = timestampMs
self.receivedAtTimestampMs = {
switch variant {
case .standardIncoming, .standardOutgoing: return Int64(Date().timeIntervalSince1970 * 1000)
/// For TSInteractions which are not `standardIncoming` and `standardOutgoing` use the `timestampMs` value
default: return timestampMs
}
}()
self.wasRead = wasRead
self.expiresInSeconds = expiresInSeconds
self.expiresStartedAtMs = expiresStartedAtMs
self.linkPreviewUrl = linkPreviewUrl
@ -186,8 +252,66 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
// MARK: - Custom Database Interaction
public mutating func didInsert(with rowID: Int64, for column: String?) {
self.id = rowID
public mutating func insert(_ db: Database) throws {
try performInsert(db)
// Since we need to do additional logic upon insert we can just set the 'id' value
// here directly instead of in the 'didInsert' method (if you look at the docs the
// 'db.lastInsertedRowID' value is the row id of the newly inserted row which the
// interaction uses as it's id)
let interactionId: Int64 = db.lastInsertedRowID
self.id = interactionId
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId) else {
SNLog("Inserted an interaction but couldn't find it's associated thead")
return
}
switch variant {
case .standardOutgoing:
// New outgoing messages should immediately determine their recipient list
// from current thread state
switch thread.variant {
case .contact:
try RecipientState(
interactionId: interactionId,
recipientId: threadId, // Will be the contact id
state: .sending
).insert(db)
case .closedGroup:
guard
let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db),
let members: [GroupMember] = try? closedGroup.members.fetchAll(db)
else {
SNLog("Inserted an interaction but couldn't find it's associated thread members")
return
}
try members.forEach { member in
try RecipientState(
interactionId: interactionId,
recipientId: member.profileId,
state: .sending
).insert(db)
}
case .openGroup:
// Since we use the 'RecipientState' type to manage the message state
// we need to ensure we have a state for all threads; so for open groups
// we just use the open group id as the 'recipientId' value
try RecipientState(
interactionId: interactionId,
recipientId: threadId, // Will be the open group id
state: .sending
).insert(db)
}
default: break
}
}
}
public func delete(_ db: Database) throws -> Bool {
@ -217,9 +341,140 @@ public struct Interaction: Codable, FetchableRecord, MutablePersistableRecord, T
}
}
// MARK: - Mutation
public extension Interaction {
func with(
serverHash: String? = nil,
authorId: String? = nil,
timestampMs: Int64? = nil,
wasRead: Bool? = nil,
expiresInSeconds: TimeInterval? = nil,
expiresStartedAtMs: Double? = nil,
openGroupServerMessageId: Int64? = nil
) -> Interaction {
return Interaction(
id: id,
serverHash: (serverHash ?? self.serverHash),
threadId: threadId,
authorId: (authorId ?? self.authorId),
variant: variant,
body: body,
timestampMs: (timestampMs ?? self.timestampMs),
receivedAtTimestampMs: receivedAtTimestampMs,
wasRead: (wasRead ?? self.wasRead),
expiresInSeconds: (expiresInSeconds ?? self.expiresInSeconds),
expiresStartedAtMs: (expiresStartedAtMs ?? self.expiresStartedAtMs),
linkPreviewUrl: linkPreviewUrl,
openGroupServerMessageId: (openGroupServerMessageId ?? self.openGroupServerMessageId),
openGroupWhisperMods: openGroupWhisperMods,
openGroupWhisperTo: openGroupWhisperTo
)
}
}
// MARK: - GRDB Interactions
public extension Interaction {
/// Immutable version of the `markAsRead(_:includingOlder:trySendReadReceipt:)` function
func markingAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws -> Interaction {
var updatedInteraction: Interaction = self
try updatedInteraction.markAsRead(db, includingOlder: includingOlder, trySendReadReceipt: trySendReadReceipt)
return updatedInteraction
}
/// This will update the `wasRead` state the the interaction
///
/// - Parameters
/// - includingOlder: Setting this to `true` will updated the `wasRead` flag for all older interactions as well
/// - trySendReadReceipt: Setting this to `true` will schedule a `ReadReceiptJob`
mutating func markAsRead(_ db: Database, includingOlder: Bool, trySendReadReceipt: Bool) throws {
// Once all of the below is done schedule the jobs
func scheduleJobs(interactionIds: [Int64]) {
// Add the 'DisappearingMessagesJob' if needed - this will update any expiring
// messages `expiresStartedAtMs` values
JobRunner.add(
db,
job: Job(
variant: .disappearingMessages,
details: DisappearingMessagesJob.updateNextRunIfNeeded(
db,
interactionIds: interactionIds,
startedAtMs: (Date().timeIntervalSince1970 * 1000)
)
)
)
// If we want to send read receipts then try to add the 'SendReadReceiptsJob'
if trySendReadReceipt {
JobRunner.upsert(
db,
job: SendReadReceiptsJob.createOrUpdateIfNeeded(
db,
threadId: threadId,
interactionIds: interactionIds
)
)
}
}
// If we aren't including older interactions then update and save the current one
guard includingOlder else {
let updatedInteraction: Interaction = try self
.with(wasRead: true)
.saved(db)
guard let id: Int64 = updatedInteraction.id else { throw GRDBStorageError.objectNotFound }
scheduleJobs(interactionIds: [id])
return
}
// Need an id in order to continue
guard let id: Int64 = self.id else { throw GRDBStorageError.objectNotFound }
let interactionQuery = Interaction
.filter(Columns.threadId == threadId)
.filter(Columns.id <= id)
// The `wasRead` flag doesn't apply to `standardOutgoing` or `standardIncomingDeleted`
.filter(Columns.variant != Variant.standardOutgoing && Columns.variant != Variant.standardIncomingDeleted)
// Update the `wasRead` flag to true
try interactionQuery.updateAll(db, Columns.wasRead.set(to: true))
// Retrieve the interaction ids we want to update
scheduleJobs(
interactionIds: try Int64.fetchAll(
db,
interactionQuery.select(Interaction.Columns.id)
)
)
}
static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws {
guard db[.areReadReceiptsEnabled] == true else { return }
try RecipientState
.filter(RecipientState.Columns.recipientId == recipientId)
.joining(
required: RecipientState.interaction
.filter(Columns.variant == Variant.standardOutgoing)
.filter(timestampMsValues.contains(Columns.timestampMs))
)
.updateAll(
db,
RecipientState.Columns.readTimestampMs.set(to: readTimestampMs),
RecipientState.Columns.state.set(to: RecipientState.State.sent)
)
}
}
// MARK: - Convenience
public extension Interaction {
static let oversizeTextMessageSizeThreshold: UInt = (2 * 1024)
// MARK: - Variables
var isExpiringMessage: Bool {
@ -230,17 +485,172 @@ public extension Interaction {
var openGroupWhisper: Bool { return (openGroupWhisperMods || (openGroupWhisperTo != nil)) }
var notificationIdentifiers: [String] {
[
notificationIdentifier(isBackgroundPoll: true),
notificationIdentifier(isBackgroundPoll: false)
]
}
// MARK: - Functions
func with(
expiresInSeconds: TimeInterval? = nil,
expiresStartedAtMs: Double? = nil,
openGroupServerMessageId: Int64? = nil
) -> Interaction {
var updatedInteraction: Interaction = self
updatedInteraction.expiresInSeconds = (expiresInSeconds ?? updatedInteraction.expiresInSeconds)
updatedInteraction.expiresStartedAtMs = (expiresStartedAtMs ?? updatedInteraction.expiresStartedAtMs)
updatedInteraction.openGroupServerMessageId = (openGroupServerMessageId ?? updatedInteraction.openGroupServerMessageId)
return updatedInteraction
func notificationIdentifier(isBackgroundPoll: Bool) -> String {
// When the app is in the background we want the notifications to be grouped to prevent spam
guard isBackgroundPoll else { return threadId }
return "\(threadId)-\(id ?? 0)"
}
func markingAsDeleted() -> Interaction {
return Interaction(
id: id,
serverHash: nil,
threadId: threadId,
authorId: authorId,
variant: .standardIncomingDeleted,
body: nil,
timestampMs: timestampMs,
receivedAtTimestampMs: receivedAtTimestampMs,
wasRead: wasRead,
expiresInSeconds: expiresInSeconds,
expiresStartedAtMs: expiresStartedAtMs,
linkPreviewUrl: linkPreviewUrl,
openGroupServerMessageId: openGroupServerMessageId,
openGroupWhisperMods: openGroupWhisperMods,
openGroupWhisperTo: openGroupWhisperTo
)
}
func isUserMentioned(_ db: Database) -> Bool {
guard variant == .standardIncoming else { return false }
let userPublicKey: String = getUserHexEncodedPublicKey(db)
return (
(
body != nil &&
(body ?? "").contains("@\(userPublicKey)")
) || (
(try? quote.fetchOne(db))?.authorId == userPublicKey
)
)
}
func previewText(_ db: Database) -> String {
switch variant {
case .standardIncomingDeleted: return ""
case .standardIncoming, .standardOutgoing:
var bodyDescription: String?
if let body: String = self.body, !body.isEmpty {
bodyDescription = body
}
if bodyDescription == nil {
let maybeTextAttachment: Attachment? = try? attachments
.filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage)
.fetchOne(db)
if
let attachment: Attachment = maybeTextAttachment,
attachment.state == .downloaded,
let filePath: String = attachment.originalFilePath,
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
let dataString: String = String(data: data, encoding: .utf8)
{
bodyDescription = dataString.filterForDisplay
}
}
var attachmentDescription: String?
let maybeMediaAttachment: Attachment? = try? attachments
.filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage)
.fetchOne(db)
if let attachment: Attachment = maybeMediaAttachment {
attachmentDescription = attachment.description
}
if
let attachmentDescription: String = attachmentDescription,
let bodyDescription: String = bodyDescription,
!attachmentDescription.isEmpty,
!bodyDescription.isEmpty
{
if CurrentAppContext().isRTL {
return "\(bodyDescription): \(attachmentDescription)"
}
return "\(attachmentDescription): \(bodyDescription)"
}
if let bodyDescription: String = bodyDescription, !bodyDescription.isEmpty {
return bodyDescription
}
if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty {
return attachmentDescription
}
if let linkPreview: LinkPreview = try? linkPreview.fetchOne(db), linkPreview.variant == .openGroupInvitation {
return "😎 Open group invitation"
}
// TODO: We should do better here
return ""
case .infoMediaSavedNotification:
// Note: This should only occur in 'contact' threads so the `threadId`
// is the contact id
let displayName: String = Profile.displayName(id: threadId)
// TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved
return String(format: "media_saved".localized(), displayName)
case .infoScreenshotNotification:
// Note: This should only occur in 'contact' threads so the `threadId`
// is the contact id
let displayName: String = Profile.displayName(id: threadId)
return String(format: "screenshot_taken".localized(), displayName)
case .infoClosedGroupCreated: return "GROUP_CREATED".localized()
case .infoClosedGroupCurrentUserLeft: return "GROUP_YOU_LEFT".localized()
case .infoClosedGroupUpdated: return (body ?? "GROUP_UPDATED".localized())
case .infoMessageRequestAccepted: return (body ?? "MESSAGE_REQUESTS_ACCEPTED".localized())
case .infoDisappearingMessagesUpdate:
// TODO: We should do better here
return (body ?? "")
}
}
func state(_ db: Database) throws -> RecipientState.State {
let states: [RecipientState.State] = try recipientStates
.fetchAll(db)
.map { $0.state }
var hasFailed: Bool = false
for state in states {
switch state {
// If there are any "sending" recipients, consider this message "sending"
case .sending: return .sending
case .failed:
hasFailed = true
break
default: break
}
}
// If there are any "failed" recipients, consider this message "failed"
guard !hasFailed else { return .failed }
// Otherwise, consider the message "sent"
//
// Note: This includes messages with no recipients
return .sent
}
}

View File

@ -0,0 +1,51 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "interactionAttachment" }
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id])
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
internal static let attachment = belongsTo(Attachment.self, using: attachmentForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case interactionId
case attachmentId
}
public let interactionId: Int64
public let attachmentId: String
// MARK: - Relationships
public var interaction: QueryInterfaceRequest<Interaction> {
request(for: InteractionAttachment.interaction)
}
public var attachment: QueryInterfaceRequest<Attachment> {
request(for: InteractionAttachment.attachment)
}
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
let quoteUses: Int? = try? Quote
.filter(Quote.Columns.attachmentId == attachmentId)
.fetchCount(db)
let linkPreviewUses: Int? = try? LinkPreview
.filter(LinkPreview.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (quoteUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
return try performDelete(db)
}
}

View File

@ -0,0 +1,218 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
import SwiftProtobuf
public struct Job: Codable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "job" }
internal static let threadForeignKey = ForeignKey(
[Columns.threadId],
to: [Interaction.Columns.threadId]
)
internal static let thread = hasOne(SessionThread.self, using: Job.threadForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
case id
case failureCount
case variant
case behaviour
case nextRunTimestamp
case threadId
case details
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
/// This is a recurring job that handles the removal of disappearing messages and is triggered
/// at the timestamp of the next disappearing message
case disappearingMessages
/// This is a recurring job that runs on launch and flags any messages marked as 'sending' to
/// be in their 'failed' state
case failedMessages = 1000
/// This is a recurring job that runs on launch and flags any attachments marked as 'uploading' to
/// be in their 'failed' state
case failedAttachmentDownloads
/// This is a recurring job that runs on return from background and registeres and uploads the
/// latest device push tokens
case syncPushTokens = 2000
/// This is a job that runs once whenever a message is sent to notify the push notification server
/// about the message
case notifyPushServer
/// This is a job that runs once at most every 3 seconds per thread whenever a message is marked as read
/// (if read receipts are enabled) to notify other members in a conversation that their message was read
case sendReadReceipts
/// This is a job that runs once whenever a message is received to attempt to decode and properly
/// process the message
case messageReceive = 3000
/// This is a job that runs once whenever a message is sent to attempt to encode and properly
/// send the message
case messageSend
/// This is a job that runs once whenever an attachment is uploaded to attempt to encode and properly
/// upload the attachment
case attachmentUpload
/// This is a job that runs once whenever an attachment is downloaded to attempt to decode and properly
/// download the attachment
case attachmentDownload
}
public enum Behaviour: Int, Codable, DatabaseValueConvertible {
/// This job will run once and then be removed from the jobs table
case runOnce
/// This job will run once the next time the app launches and then be removed from the jobs table
case runOnceNextLaunch
/// This job will run and then will be updated with a new `nextRunTimestamp` (at least 1 second in
/// the future) in order to be run again
case recurring
/// This job will run once each launch
case recurringOnLaunch
/// This job will run once each whenever the app becomes active (launch and return from background)
case recurringOnActive
}
/// The `id` value is auto incremented by the database, if the `Job` hasn't been inserted into
/// the database yet this value will be `nil`
public var id: Int64? = nil
/// A counter for the number of times this job has failed
public let failureCount: UInt
/// The type of job
public let variant: Variant
/// The type of job
public let behaviour: Behaviour
/// Seconds since epoch to indicate the next datetime that this job should run
public let nextRunTimestamp: TimeInterval
/// The id of the thread this job is associated with
///
/// **Note:** This will only be populated for Jobs associated to threads
public let threadId: String?
/// JSON encoded data required for the job
public let details: Data?
// MARK: - Relationships
public var thread: QueryInterfaceRequest<SessionThread> {
request(for: Job.thread)
}
// MARK: - Initialization
fileprivate init(
id: Int64?,
failureCount: UInt,
variant: Variant,
behaviour: Behaviour,
nextRunTimestamp: TimeInterval,
threadId: String?,
details: Data?
) {
self.id = id
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.details = details
}
public init(
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil
) {
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.details = nil
}
public init?<T: Encodable>(
failureCount: UInt = 0,
variant: Variant,
behaviour: Behaviour = .runOnce,
nextRunTimestamp: TimeInterval = 0,
threadId: String? = nil,
details: T? = nil
) {
let detailsData: Data?
if let details: T = details {
guard let encodedDetails: Data = try? JSONEncoder().encode(details) else { return nil }
detailsData = encodedDetails
}
else {
detailsData = nil
}
self.failureCount = failureCount
self.variant = variant
self.behaviour = behaviour
self.nextRunTimestamp = nextRunTimestamp
self.threadId = threadId
self.details = detailsData
}
// MARK: - Custom Database Interaction
public mutating func didInsert(with rowID: Int64, for column: String?) {
self.id = rowID
}
}
// MARK: - Convenience
public extension Job {
internal func with(
failureCount: UInt = 0,
nextRunTimestamp: TimeInterval?
) -> Job {
return Job(
id: id,
failureCount: failureCount,
variant: variant,
behaviour: behaviour,
nextRunTimestamp: (nextRunTimestamp ?? self.nextRunTimestamp),
threadId: threadId,
details: details
)
}
internal func with<T: Encodable>(details: T) -> Job? {
guard let detailsData: Data = try? JSONEncoder().encode(details) else { return nil }
return Job(
id: id,
failureCount: failureCount,
variant: variant,
behaviour: behaviour,
nextRunTimestamp: nextRunTimestamp,
threadId: threadId,
details: detailsData
)
}
}

View File

@ -10,7 +10,9 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec
[Columns.url],
to: [Interaction.Columns.linkPreviewUrl]
)
private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id])
internal static let interactions = hasMany(Interaction.self, using: Interaction.linkPreviewForeignKey)
internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey)
/// We want to cache url previews to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to ensure the user isn't shown a preview that is too stale
internal static let timstampResolution: Double = 100000
@ -21,6 +23,7 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec
case timestamp
case variant
case title
case attachmentId
}
public enum Variant: Int, Codable, DatabaseValueConvertible {
@ -40,14 +43,194 @@ public struct LinkPreview: Codable, FetchableRecord, PersistableRecord, TableRec
/// The title for the link
public let title: String?
/// The id for the attachment for the link preview image
public let attachmentId: String?
// MARK: - Relationships
public var attachment: QueryInterfaceRequest<Attachment> {
request(for: LinkPreview.attachment)
}
// MARK: - Initialization
public init(
url: String,
timestamp: TimeInterval = LinkPreview.timestampFor(
sentTimestampMs: (Date().timeIntervalSince1970 * 1000) // Default to now
),
variant: Variant = .standard,
title: String?,
attachmentId: String? = nil
) {
self.url = url
self.timestamp = timestamp
self.variant = variant
self.title = title
self.attachmentId = attachmentId
}
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
if let attachmentId: String = attachmentId {
let interactionUses: Int? = try? InteractionAttachment
.filter(InteractionAttachment.Columns.attachmentId == attachmentId)
.fetchCount(db)
let quoteUses: Int? = try? Quote
.filter(Quote.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (interactionUses ?? 0) == 0 && (quoteUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
}
return try performDelete(db)
}
}
// MARK: - Protobuf
public extension LinkPreview {
init?(_ db: Database, proto: SNProtoDataMessage, body: String?) throws {
guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview }
guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview }
guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput }
guard URL(string: previewProto.url) != nil else { throw LinkPreviewError.invalidInput }
guard LinkPreview.isValidLinkUrl(previewProto.url) else { throw LinkPreviewError.invalidInput }
guard let body: String = body else { throw LinkPreviewError.invalidInput }
guard LinkPreview.allPreviewUrls(forMessageBodyText: body).contains(previewProto.url) else {
throw LinkPreviewError.invalidInput
}
// Try to get an existing link preview first
let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(proto.timestamp))
let maybeLinkPreview: LinkPreview? = try? LinkPreview
.filter(LinkPreview.Columns.url == previewProto.url)
.filter(LinkPreview.Columns.timestamp == LinkPreview.timestampFor(
sentTimestampMs: Double(proto.timestamp)
))
.fetchOne(db)
if let linkPreview: LinkPreview = maybeLinkPreview {
self = linkPreview
return
}
self.url = previewProto.url
self.timestamp = timestamp
self.variant = .standard
self.title = LinkPreview.normalizeTitle(title: previewProto.title)
if let imageProto = previewProto.image {
let attachment: Attachment = Attachment(proto: imageProto)
try attachment.insert(db)
self.attachmentId = attachment.id
}
else {
self.attachmentId = nil
}
// Make sure the quote is valid before completing
guard self.title != nil || self.attachmentId != nil else { throw LinkPreviewError.invalidInput }
}
}
// MARK: - Convenience
public extension LinkPreview {
struct URLMatchResult {
let urlString: String
let matchRange: NSRange
}
static func timestampFor(sentTimestampMs: Double) -> TimeInterval {
// We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler than 86,400) to optimise
// LinkPreview storage without having too stale data
// We want to round the timestamp down to the nearest 100,000 seconds (~28 hours - simpler
// than 86,400) to optimise LinkPreview storage without having too stale data
return (floor(sentTimestampMs / 1000 / LinkPreview.timstampResolution) * LinkPreview.timstampResolution)
}
@discardableResult static func saveAttachmentIfPossible(_ db: Database, imageData: Data?, mimeType: String) throws -> String? {
guard let imageData: Data = imageData, !imageData.isEmpty else { return nil }
guard let fileExtension: String = MIMETypeUtil.fileExtension(forMIMEType: mimeType) else { return nil }
let filePath = OWSFileSystem.temporaryFilePath(withFileExtension: fileExtension)
try imageData.write(to: NSURL.fileURL(withPath: filePath), options: .atomicWrite)
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath, shouldDeleteOnDeallocation: true) else {
return nil
}
return try Attachment(contentType: mimeType, dataSource: dataSource)?
.inserted(db)
.id
}
static func isValidLinkUrl(_ urlString: String) -> Bool {
return URL(string: urlString) != nil
}
static func allPreviewUrls(forMessageBodyText body: String) -> [String] {
return allPreviewUrlMatches(forMessageBodyText: body).map { $0.urlString }
}
// MARK: - Private Methods
private static func allPreviewUrlMatches(forMessageBodyText body: String) -> [URLMatchResult] {
let detector: NSDataDetector
do {
detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
}
catch {
return []
}
var urlMatches: [URLMatchResult] = []
let matches = detector.matches(in: body, options: [], range: NSRange(location: 0, length: body.count))
for match in matches {
guard let matchURL = match.url else { continue }
// If the URL entered didn't have a scheme it will default to 'http', we want to catch this and
// set the scheme to 'https' instead as we don't load previews for 'http' so this will result
// in more previews actually getting loaded without forcing the user to enter 'https://' before
// every URL they enter
let urlString: String = (matchURL.absoluteString == "http://\(body)" ?
"https://\(body)" :
matchURL.absoluteString
)
if isValidLinkUrl(urlString) {
let matchResult = URLMatchResult(urlString: urlString, matchRange: match.range)
urlMatches.append(matchResult)
}
}
return urlMatches
}
fileprivate static func normalizeTitle(title: String?) -> String? {
guard var result: String = title, !result.isEmpty else { return nil }
// Truncate title after 2 lines of text.
let maxLineCount = 2
var components = result.components(separatedBy: .newlines)
if components.count > maxLineCount {
components = Array(components[0..<maxLineCount])
result = components.joined(separator: "\n")
}
let maxCharacterCount = 2048
if result.count > maxCharacterCount {
let endIndex = result.index(result.startIndex, offsetBy: maxCharacterCount)
result = String(result[..<endIndex])
}
return result.filterStringForDisplay()
}
}

View File

@ -94,7 +94,7 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco
infoUpdates: Int
) {
// Always force the server to lowercase
self.threadId = "\(server.lowercased()).\(room)" // TODO: Validate this (doesn't seem to happen in the old code...)
self.threadId = OpenGroup.idFor(room: room, server: server)
self.server = server.lowercased()
self.room = room
self.publicKey = publicKey
@ -116,3 +116,11 @@ public struct OpenGroup: Codable, Identifiable, FetchableRecord, PersistableReco
return try performDelete(db)
}
}
// MARK: - Convenience
public extension OpenGroup {
static func idFor(room: String, server: String) -> String {
return "\(server.lowercased()).\(room)"
}
}

View File

@ -224,32 +224,42 @@ public extension Profile {
// MARK: - GRDB Interactions
public extension Profile {
static func displayName(for id: ID, thread: TSThread, customFallback: String? = nil) -> String {
static func displayName(_ db: Database? = nil, id: ID, thread: SessionThread, customFallback: String? = nil) -> String {
return displayName(
for: id,
context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular),
db,
id: id,
context: (thread.variant == .openGroup ? .openGroup : .regular),
customFallback: customFallback
)
}
static func displayName(for id: ID, context: Context = .regular, customFallback: String? = nil) -> String {
let existingDisplayName: String? = GRDBStorage.shared
.read { db in try Profile.fetchOne(db, id: id) }?
static func displayName(_ db: Database? = nil, id: ID, context: Context = .regular, customFallback: String? = nil) -> String {
guard let db: Database = db else {
return GRDBStorage.shared
.read { db in displayName(db, id: id, context: context, customFallback: customFallback) }
.defaulting(to: (customFallback ?? id))
}
let existingDisplayName: String? = (try? Profile.fetchOne(db, id: id))?
.displayName(for: context)
return (existingDisplayName ?? (customFallback ?? id))
}
static func displayNameNoFallback(for id: ID, thread: TSThread) -> String? {
static func displayNameNoFallback(_ db: Database? = nil, id: ID, thread: SessionThread) -> String? {
return displayName(
for: id,
context: ((thread as? TSGroupThread)?.isOpenGroup == true ? .openGroup : .regular)
db,
id: id,
context: (thread.variant == .openGroup ? .openGroup : .regular)
)
}
static func displayNameNoFallback(for id: ID, context: Context = .regular) -> String? {
return GRDBStorage.shared
.read { db in try Profile.fetchOne(db, id: id) }?
static func displayNameNoFallback(_ db: Database? = nil, id: ID, context: Context = .regular) -> String? {
guard let db: Database = db else {
return GRDBStorage.shared.read { db in displayNameNoFallback(db, id: id, context: context) }
}
return (try? Profile.fetchOne(db, id: id))?
.displayName(for: context)
}
@ -344,11 +354,11 @@ public class SMKProfile: NSObject {
}
@objc public static func displayName(id: String) -> String {
return Profile.displayName(for: id)
return Profile.displayName(id: id)
}
@objc public static func displayName(id: String, customFallback: String) -> String {
return Profile.displayName(for: id, customFallback: customFallback)
return Profile.displayName(id: id, customFallback: customFallback)
}
@objc public static func displayName(id: String, context: Profile.Context = .regular) -> String {
@ -359,8 +369,8 @@ public class SMKProfile: NSObject {
return (existingProfile?.name ?? id)
}
@objc public static func displayName(id: String, thread: TSThread) -> String {
return Profile.displayName(for: id, thread: thread)
public static func displayName(id: String, thread: SessionThread) -> String {
return Profile.displayName(id: id, thread: thread)
}
@objc public static var localProfileKey: OWSAES256Key? {

View File

@ -12,9 +12,11 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C
to: [Interaction.Columns.timestampMs, Interaction.Columns.authorId]
)
internal static let profileForeignKey = ForeignKey([Columns.authorId], to: [Profile.Columns.id])
private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
private static let attachmentForeignKey = ForeignKey([Columns.attachmentId], to: [Attachment.Columns.id])
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
private static let profile = hasOne(Profile.self, using: profileForeignKey)
private static let quotedInteraction = hasOne(Interaction.self, using: originalInteractionForeignKey)
internal static let attachment = hasOne(Attachment.self, using: attachmentForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
@ -22,6 +24,7 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C
case authorId
case timestampMs
case body
case attachmentId
}
/// The id for the interaction this Quote belongs to
@ -31,11 +34,14 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C
public let authorId: String
/// The timestamp in milliseconds since epoch when the quoted interaction was sent
public let timestampMs: Double
public let timestampMs: Int64
/// The body of the quoted message if the user is quoting a text message or an attachment with a caption
public let body: String?
/// The id for the attachment this Quote is associated with
public let attachmentId: String?
// MARK: - Relationships
public var interaction: QueryInterfaceRequest<Interaction> {
@ -46,7 +52,113 @@ public struct Quote: Codable, FetchableRecord, PersistableRecord, TableRecord, C
request(for: Quote.profile)
}
public var attachment: QueryInterfaceRequest<Attachment> {
request(for: Quote.attachment)
}
public var originalInteraction: QueryInterfaceRequest<Interaction> {
request(for: Quote.quotedInteraction)
}
// MARK: - Interaction
public init(
interactionId: Int64,
authorId: String,
timestampMs: Int64,
body: String?,
attachmentId: String?
) {
self.interactionId = interactionId
self.authorId = authorId
self.timestampMs = timestampMs
self.body = body
self.attachmentId = attachmentId
}
// MARK: - Custom Database Interaction
public func delete(_ db: Database) throws -> Bool {
// If we have an Attachment then check if this is the only type that is referencing it
// and delete the Attachment if so
if let attachmentId: String = attachmentId {
let interactionUses: Int? = try? InteractionAttachment
.filter(InteractionAttachment.Columns.attachmentId == attachmentId)
.fetchCount(db)
let linkPreviewUses: Int? = try? LinkPreview
.filter(LinkPreview.Columns.attachmentId == attachmentId)
.fetchCount(db)
if (interactionUses ?? 0) == 0 && (linkPreviewUses ?? 0) == 0 {
try attachment.deleteAll(db)
}
}
return try performDelete(db)
}
}
// MARK: - Protobuf
public extension Quote {
init?(_ db: Database, proto: SNProtoDataMessage, interactionId: Int64, thread: SessionThread) throws {
guard
let quote = proto.quote,
quote.id != 0,
!quote.author.isEmpty
else { return nil }
self.interactionId = interactionId
self.timestampMs = Int64(quote.id)
self.authorId = quote.author
// Prefer to generate the text snippet locally if available.
let quotedInteraction: Interaction? = try? thread
.interactions
.filter(Interaction.Columns.authorId == quote.author)
.filter(Interaction.Columns.timestampMs == Double(quote.id))
.fetchOne(db)
if let quotedInteraction: Interaction = quotedInteraction, quotedInteraction.body?.isEmpty == false {
self.body = quotedInteraction.body
}
else if let body: String = proto.body, !body.isEmpty {
self.body = body
}
else {
self.body = nil
}
// We only use the first attachment
if let attachment = proto.attachments.first {
let thumbnailAttachment: Attachment
// We prefer deriving any thumbnail locally rather than fetching one from the network
if let quotedInteraction: Interaction = quotedInteraction {
if let attachment: Attachment = try? quotedInteraction.attachments.fetchOne(db) {
thumbnailAttachment = attachment.cloneAsThumbnail()
}
else if let linkPreviewAttachment: Attachment = try? quotedInteraction.linkPreview.fetchOne(db)?.attachment.fetchOne(db) {
thumbnailAttachment = linkPreviewAttachment.cloneAsThumbnail()
}
else {
thumbnailAttachment = Attachment(proto: attachment)
}
}
else {
thumbnailAttachment = Attachment(proto: attachment)
}
try thumbnailAttachment.save(db)
self.attachmentId = thumbnailAttachment.id
}
else {
self.attachmentId = nil
}
// Make sure the quote is valid before completing
if self.body == nil && self.attachmentId == nil {
return nil
}
}
}

View File

@ -4,12 +4,12 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct RecipientState: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct RecipientState: Codable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "recipientState" }
internal static let profileForeignKey = ForeignKey([Columns.recipientId], to: [Profile.Columns.id])
internal static let interactionForeignKey = ForeignKey([Columns.interactionId], to: [Interaction.Columns.id])
private static let profile = hasOne(Profile.self, using: profileForeignKey)
private static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
internal static let interaction = belongsTo(Interaction.self, using: interactionForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
@ -17,6 +17,7 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table
case recipientId
case state
case readTimestampMs
case mostRecentFailureText
}
public enum State: Int, Codable, DatabaseValueConvertible {
@ -41,7 +42,9 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table
///
/// **Note:** This currently will be set when opening the thread for the first time after receiving this interaction
/// rather than when the interaction actually appears on the screen
public fileprivate(set) var readTimestampMs: Double? = nil // TODO: Add setter
public let readTimestampMs: Int64?
public let mostRecentFailureText: String?
// MARK: - Relationships
@ -52,4 +55,38 @@ public struct RecipientState: Codable, FetchableRecord, PersistableRecord, Table
public var profile: QueryInterfaceRequest<Profile> {
request(for: RecipientState.profile)
}
// MARK: - Initialization
public init(
interactionId: Int64,
recipientId: String,
state: State,
readTimestampMs: Int64? = nil,
mostRecentFailureText: String? = nil
) {
self.interactionId = interactionId
self.recipientId = recipientId
self.state = state
self.readTimestampMs = readTimestampMs
self.mostRecentFailureText = mostRecentFailureText
}
}
// MARK: - Mutation
public extension RecipientState {
func with(
state: State? = nil,
readTimestampMs: Int64? = nil,
mostRecentFailureText: String? = nil
) -> RecipientState {
return RecipientState(
interactionId: interactionId,
recipientId: recipientId,
state: (state ?? self.state),
readTimestampMs: (readTimestampMs ?? self.readTimestampMs),
mostRecentFailureText: (mostRecentFailureText ?? self.mostRecentFailureText)
)
}
}

View File

@ -4,15 +4,16 @@ import Foundation
import GRDB
import SessionUtilitiesKit
public struct SessionThread: Codable, Identifiable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public struct SessionThread: Codable, Identifiable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible {
public static var databaseTableName: String { "thread" }
private static let contact = hasOne(Contact.self, using: Contact.threadForeignKey)
private static let closedGroup = hasOne(ClosedGroup.self, using: ClosedGroup.threadForeignKey)
private static let openGroup = hasOne(OpenGroup.self, using: OpenGroup.threadForeignKey)
private static let disappearingMessagesConfiguration = hasOne(
DisappearingMessagesConfiguration.self,
using: DisappearingMessagesConfiguration.threadForeignKey
)
private static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey)
public static let interactions = hasMany(Interaction.self, using: Interaction.threadForeignKey)
public typealias Columns = CodingKeys
public enum CodingKeys: String, CodingKey, ColumnExpression {
@ -23,6 +24,7 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable
case isPinned
case messageDraft
case notificationMode
case notificationSound
case mutedUntilTimestamp
}
@ -45,10 +47,15 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable
public let isPinned: Bool
public let messageDraft: String?
public let notificationMode: NotificationMode
public let notificationSound: Preferences.Sound?
public let mutedUntilTimestamp: TimeInterval?
// MARK: - Relationships
public var contact: QueryInterfaceRequest<Contact> {
request(for: SessionThread.contact)
}
public var closedGroup: QueryInterfaceRequest<ClosedGroup> {
request(for: SessionThread.closedGroup)
}
@ -65,4 +72,88 @@ public struct SessionThread: Codable, Identifiable, FetchableRecord, Persistable
request(for: SessionThread.interactions)
}
// MARK: - Initialization
public init(
id: String,
variant: Variant,
creationDateTimestamp: TimeInterval = Date().timeIntervalSince1970,
shouldBeVisible: Bool = false,
isPinned: Bool = false,
messageDraft: String? = nil,
notificationMode: NotificationMode = .all,
notificationSound: Preferences.Sound? = nil,
mutedUntilTimestamp: TimeInterval? = nil
) {
self.id = id
self.variant = variant
self.creationDateTimestamp = creationDateTimestamp
self.shouldBeVisible = shouldBeVisible
self.isPinned = isPinned
self.messageDraft = messageDraft
self.notificationMode = notificationMode
self.notificationSound = notificationSound
self.mutedUntilTimestamp = mutedUntilTimestamp
}
}
// MARK: - GRDB Interactions
public extension SessionThread {
/// The `variant` will be ignored if an existing thread is found
static func fetchOrCreate(_ db: Database, id: ID, variant: Variant) -> SessionThread {
return ((try? fetchOne(db, id: id)) ?? SessionThread(id: id, variant: variant))
}
static func messageRequestThreads(_ db: Database) -> QueryInterfaceRequest<SessionThread> {
return SessionThread
.filter(Columns.shouldBeVisible == true)
.filter(Columns.variant == Variant.contact)
.filter(Columns.id != getUserHexEncodedPublicKey(db))
.joining(
optional: contact
.filter(Contact.Columns.isApproved == false)
)
}
func isMessageRequest(_ db: Database) -> Bool {
return (
shouldBeVisible &&
variant == .contact &&
id != getUserHexEncodedPublicKey(db) && // Note to self
(try? Contact.fetchOne(db, id: id))?.isApproved != true
)
}
}
// MARK: - Convenience
public extension SessionThread {
func isNoteToSelf(_ db: Database? = nil) -> Bool {
return (
variant == .contact &&
id == getUserHexEncodedPublicKey(db)
)
}
func name(_ db: Database) -> String {
switch variant {
case .contact: return Profile.displayName(db, id: id)
case .closedGroup:
guard let name: String = try? closedGroup.fetchOne(db)?.name, !name.isEmpty else {
return "Group"
}
return name
case .openGroup:
guard let name: String = try? openGroup.fetchOne(db)?.name, !name.isEmpty else {
return "Group"
}
return name
}
}
}

View File

@ -56,3 +56,27 @@ public class SSKPreferences: NSObject {
OWSPrimaryStorage.dbReadWriteConnection().setBool(value, forKey: key, inCollection: collection)
}
}
// MARK: - Objective C Support
public extension SSKPreferences {
@objc(setScreenSecurity:)
static func objc_setScreenSecurity(_ enabled: Bool) {
GRDBStorage.shared.write { db in db[.preferencesAppSwitcherPreviewEnabled] = enabled }
}
@objc(setAreReadReceiptsEnabled:)
static func objc_setAreReadReceiptsEnabled(_ enabled: Bool) {
GRDBStorage.shared.write { db in db[.areReadReceiptsEnabled] = enabled }
}
@objc(setTypingIndicatorsEnabled:)
static func objc_setTypingIndicatorsEnabled(_ enabled: Bool) {
GRDBStorage.shared.write { db in db[.typingIndicatorsEnabled] = enabled }
}
@objc(areTypingIndicatorsEnabled)
static func objc_areTypingIndicatorsEnabled() -> Bool {
return (GRDBStorage.shared.read { db in db[.typingIndicatorsEnabled] } == true)
}
}

View File

@ -1,3 +1,5 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
import SessionSnodeKit

View File

@ -1,12 +0,0 @@
@objc(SNJob)
public protocol Job : NSCoding {
var delegate: JobDelegate? { get set }
var id: String? { get set }
var failureCount: UInt { get set }
static var collection: String { get }
static var maxFailureCount: UInt { get }
func execute()
}

View File

@ -0,0 +1,395 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
public protocol JobExecutor {
static var maxFailureCount: UInt { get }
static var requiresThreadId: Bool { get }
/// This method contains the logic needed to complete a job
///
/// **Note:** The code in this method should run synchronously and the various
/// "result" blocks should not be called within a database closure
///
/// - Parameters:
/// - job: The job which is being run
/// - success: The closure which is called when the job succeeds (with an
/// updated `job` and a flag indicating whether the job should forcibly stop running)
/// - failure: The closure which is called when the job fails (with an updated
/// `job`, an `Error` (if applicable) and a flag indicating whether it was a permanent
/// failure)
/// - deferred: The closure which is called when the job is deferred (with an
/// updated `job`)
static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
)
}
public final class JobRunner {
private class Trigger {
private var timer: Timer?
static func create(timestamp: TimeInterval) -> Trigger {
let trigger: Trigger = Trigger()
trigger.timer = Timer.scheduledTimer(
timeInterval: timestamp,
target: self,
selector: #selector(start),
userInfo: nil,
repeats: false
)
return trigger
}
deinit { timer?.invalidate() }
@objc func start() {
JobRunner.start()
}
}
// TODO: Could this be a bottleneck? (single serial queue to process all these jobs? Group by thread?)
// TODO: Multi-thread support
private static let minRetryInterval: TimeInterval = 1
private static let queueKey: DispatchSpecificKey = DispatchSpecificKey<String>()
private static let queueContext: String = "JobRunner"
private static let internalQueue: DispatchQueue = {
let result: DispatchQueue = DispatchQueue(label: queueContext)
result.setSpecific(key: queueKey, value: queueContext)
return result
}()
internal static var executorMap: Atomic<[Job.Variant: JobExecutor.Type]> = Atomic([:])
private static var nextTrigger: Atomic<Trigger?> = Atomic(nil)
private static var isRunning: Atomic<Bool> = Atomic(false)
private static var jobQueue: Atomic<[Job]> = Atomic([])
private static var jobsCurrentlyRunning: Atomic<Set<Int64>> = Atomic([])
// MARK: - Configuration
public static func add(executor: JobExecutor.Type, for variant: Job.Variant) {
executorMap.mutate { $0[variant] = executor }
}
// MARK: - Execution
public static func add(_ db: Database, job: Job?, canStartJob: Bool = true) {
// Store the job into the database (getting an id for it)
guard let updatedJob: Job = try? job?.inserted(db) else {
SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job")
return
}
switch (canStartJob, updatedJob.behaviour) {
case (false, _), (_, .runOnceNextLaunch): return
default: break
}
jobQueue.mutate { $0.append(updatedJob) }
// Start the job runner if needed
db.afterNextTransactionCommit { _ in
if !isRunning.wrappedValue {
start()
}
}
}
public static func upsert(_ db: Database, job: Job?, canStartJob: Bool = true) {
guard let job: Job = job else { return } // Ignore null jobs
guard let jobId: Int64 = job.id else {
add(db, job: job, canStartJob: canStartJob)
return
}
// Lock the queue while checking the index and inserting to ensure we don't run into
// any multi-threading shenanigans
var didUpdateExistingJob: Bool = false
jobQueue.mutate { queue in
if let jobIndex: Array<Job>.Index = queue.firstIndex(where: { $0.id == jobId }) {
queue[jobIndex] = job
didUpdateExistingJob = true
}
}
// If we didn't update an existing job then we need to add it to the queue
guard !didUpdateExistingJob else { return }
add(db, job: job, canStartJob: canStartJob)
}
public static func insert(_ db: Database, job: Job?, before otherJob: Job) {
switch job?.behaviour {
case .recurringOnActive, .recurringOnLaunch, .runOnceNextLaunch:
SNLog("[JobRunner] Attempted to insert \(job.map { "\($0.variant)" } ?? "unknown") job before the current one even though it's behaviour is \(job.map { "\($0.behaviour)" } ?? "unknown")")
return
default: break
}
// Store the job into the database (getting an id for it)
guard let updatedJob: Job = try? job?.inserted(db) else {
SNLog("[JobRunner] Unable to add \(job.map { "\($0.variant)" } ?? "unknown") job")
return
}
// Insert the job before the current job (re-adding the current job to
// the start of the queue if it's not in there) - this will mean the new
// job will run and then the otherJob will run (or run again) once it's
// done
jobQueue.mutate {
if !$0.contains(otherJob) {
$0.insert(otherJob, at: 0)
}
guard let otherJobIndex: Int = $0.firstIndex(of: otherJob) else { return }
$0.insert(updatedJob, at: otherJobIndex)
}
}
public static func appDidFinishLaunching() {
// Note: 'appDidBecomeActive' will run on first launch anyway so we can
// leave those jobs out and can wait until then to start the JobRunner
let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in
try Job
.filter(
[
Job.Behaviour.recurringOnLaunch,
Job.Behaviour.runOnceNextLaunch
].contains(Job.Columns.behaviour)
)
.fetchAll(db)
}
guard let jobsToRun: [Job] = maybeJobsToRun else { return }
jobQueue.mutate { $0.append(contentsOf: jobsToRun) }
}
public static func appDidBecomeActive() {
let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in
try Job
.filter(Job.Columns.behaviour == Job.Behaviour.recurringOnActive)
.fetchAll(db)
}
guard let jobsToRun: [Job] = maybeJobsToRun else { return }
jobQueue.mutate { $0.append(contentsOf: jobsToRun) }
// Start the job runner if needed
if !isRunning.wrappedValue {
start()
}
}
public static func isCurrentlyRunning(_ job: Job?) -> Bool {
guard let job: Job = job, let jobId: Int64 = job.id else { return false }
return jobsCurrentlyRunning.wrappedValue.contains(jobId)
}
// MARK: - Job Running
public static func start() {
// We only want the JobRunner to run in the main app
guard CurrentAppContext().isMainApp else { return }
guard !isRunning.wrappedValue else { return }
// The JobRunner runs synchronously we need to ensure this doesn't start
// on the main thread (if it is on the main thread then swap to a different thread)
guard DispatchQueue.getSpecific(key: queueKey) == queueContext else {
internalQueue.async {
start()
}
return
}
// Get any pending jobs
let maybeJobsToRun: [Job]? = GRDBStorage.shared.read { db in
try Job
.filter(
[
Job.Behaviour.runOnce,
Job.Behaviour.recurring
].contains(Job.Columns.behaviour)
)
.filter(Job.Columns.nextRunTimestamp <= Date().timeIntervalSince1970)
.order(Job.Columns.nextRunTimestamp)
.fetchAll(db)
}
// If there are no pending jobs then schedule the JobRunner to start again
// when the next scheduled job should start
guard let jobsToRun: [Job] = maybeJobsToRun else {
scheduleNextSoonestJob()
return
}
// Add the jobs to the queue and run the first job in the queue
jobQueue.mutate { $0.append(contentsOf: jobsToRun) }
runNextJob()
}
private static func runNextJob() {
// Ensure this is running on the correct queue
guard DispatchQueue.getSpecific(key: queueKey) == queueContext else {
internalQueue.async {
runNextJob()
}
return
}
guard let nextJob: Job = jobQueue.mutate({ $0.popFirst() }) else {
scheduleNextSoonestJob()
isRunning.mutate { $0 = false }
return
}
guard let jobExecutor: JobExecutor.Type = executorMap.wrappedValue[nextJob.variant] else {
SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing executor")
handleJobFailed(nextJob, error: JobRunnerError.executorMissing, permanentFailure: true)
return
}
guard !jobExecutor.requiresThreadId || nextJob.threadId != nil else {
SNLog("[JobRunner] Unable to run \(nextJob.variant) job due to missing required threadId")
handleJobFailed(nextJob, error: JobRunnerError.requiredThreadIdMissing, permanentFailure: true)
return
}
// Update the state to indicate it's running
//
// Note: We need to store 'numJobsRemaining' in it's own variable because
// the 'SNLog' seems to dispatch to it's own queue which ends up getting
// blocked by the JobRunner's queue becuase 'jobQueue' is Atomic
let numJobsRemaining: Int = jobQueue.wrappedValue.count
nextTrigger.mutate { $0 = nil }
isRunning.mutate { $0 = true }
jobsCurrentlyRunning.mutate { $0 = $0.inserting(nextJob.id) }
SNLog("[JobRunner] Start job (\(numJobsRemaining) remaining)")
jobExecutor.run(
nextJob,
success: handleJobSucceeded,
failure: handleJobFailed,
deferred: handleJobDeferred
)
}
private static func scheduleNextSoonestJob() {
let maybeJob: Job? = GRDBStorage.shared.read { db in
try Job
.filter(
[
Job.Behaviour.runOnce,
Job.Behaviour.recurring
].contains(Job.Columns.behaviour)
)
.order(Job.Columns.nextRunTimestamp)
.fetchOne(db)
}
let targetTimestamp: TimeInterval = (maybeJob?.nextRunTimestamp ?? (Date().timeIntervalSince1970 + minRetryInterval))
nextTrigger.mutate { $0 = Trigger.create(timestamp: targetTimestamp) }
}
// MARK: - Handling Results
/// This function is called when a job succeeds
private static func handleJobSucceeded(_ job: Job, shouldStop: Bool) {
switch job.behaviour {
case .runOnce, .runOnceNextLaunch:
GRDBStorage.shared.write { db in
try job.delete(db)
}
case .recurring where shouldStop == true:
GRDBStorage.shared.write { db in
try job.delete(db)
}
case .recurring where job.nextRunTimestamp <= Date().timeIntervalSince1970:
// For `recurring` jobs we want the job to run again but want at least 1 second to pass
GRDBStorage.shared.write { db in
var updatedJob: Job = job.with(
nextRunTimestamp: (Date().timeIntervalSince1970 + 1)
)
try updatedJob.save(db)
}
default: break
}
// The job is removed from the queue before it runs so all we need to to is remove it
// from the 'currentlyRunning' set and start the next one
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
runNextJob()
}
/// This function is called when a job fails, if it's wasn't a permanent failure then the 'failureCount' for the job will be incremented and it'll
/// be re-run after a retry interval has passed
private static func handleJobFailed(_ job: Job, error: Error?, permanentFailure: Bool) {
guard GRDBStorage.shared.read({ db in try Job.exists(db, id: job.id ?? -1) }) == true else {
SNLog("[JobRunner] \(job.variant) job canceled")
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
runNextJob()
return
}
GRDBStorage.shared.write { db in
// Check if the job has a 'maxFailureCount' (a value of '0' means it will always retry)
let maxFailureCount: UInt = (executorMap.wrappedValue[job.variant]?.maxFailureCount ?? 0)
guard
!permanentFailure &&
maxFailureCount > 0 &&
job.failureCount + 1 < maxFailureCount
else {
// If the job permanently failed or we have performed all of our retry attempts
// then delete the job (it'll probably never succeed)
try job.delete(db)
return
}
SNLog("[JobRunner] \(job.variant) job failed; scheduling retry (failure count is \(job.failureCount + 1))")
_ = try job
.with(
failureCount: (job.failureCount + 1),
nextRunTimestamp: (Date().timeIntervalSince1970 + getRetryInterval(for: job))
)
.saved(db)
}
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
runNextJob()
}
/// This function is called when a job neither succeeds or fails (this should only occur if the job has specific logic that makes it dependant
/// on other jobs, and it should automatically manage those dependencies)
private static func handleJobDeferred(_ job: Job) {
jobsCurrentlyRunning.mutate { $0 = $0.removing(job.id) }
runNextJob()
}
// MARK: - Convenience
private static func getRetryInterval(for job: Job) -> TimeInterval {
// Arbitrary backoff factor...
// try 1 delay: 0.5s
// try 2 delay: 1s
// ...
// try 5 delay: 16s
// ...
// try 11 delay: 512s
let maxBackoff: Double = 10 * 60 // 10 minutes
return 0.25 * min(maxBackoff, pow(2, Double(job.failureCount)))
}
}

View File

@ -0,0 +1,12 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
public enum JobRunnerError: Error {
case generic
case executorMissing
case requiredThreadIdMissing
case missingRequiredDetails
}

View File

@ -1,111 +0,0 @@
import SessionUtilitiesKit
import PromiseKit
public final class MessageReceiveJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public let data: Data
public let serverHash: String?
public let openGroupMessageServerID: UInt64?
public let openGroupID: String?
public let isBackgroundPoll: Bool
public var delegate: JobDelegate?
public var id: String?
public var failureCount: UInt = 0
// MARK: Settings
public class var collection: String { return "MessageReceiveJobCollection" }
public static let maxFailureCount: UInt = 10
// MARK: Initialization
public init(data: Data, serverHash: String? = nil, openGroupMessageServerID: UInt64? = nil, openGroupID: String? = nil, isBackgroundPoll: Bool) {
self.data = data
self.serverHash = serverHash
self.openGroupMessageServerID = openGroupMessageServerID
self.openGroupID = openGroupID
self.isBackgroundPoll = isBackgroundPoll
#if DEBUG
if openGroupMessageServerID != nil { assert(openGroupID != nil) }
if openGroupID != nil { assert(openGroupMessageServerID != nil) }
#endif
}
// MARK: Coding
public init?(coder: NSCoder) {
guard let data = coder.decodeObject(forKey: "data") as! Data?,
let id = coder.decodeObject(forKey: "id") as! String?,
let isBackgroundPoll = coder.decodeObject(forKey: "isBackgroundPoll") as! Bool? else { return nil }
self.data = data
self.serverHash = coder.decodeObject(forKey: "serverHash") as! String?
self.openGroupMessageServerID = coder.decodeObject(forKey: "openGroupMessageServerID") as! UInt64?
self.openGroupID = coder.decodeObject(forKey: "openGroupID") as! String?
self.isBackgroundPoll = isBackgroundPoll
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
}
public func encode(with coder: NSCoder) {
coder.encode(data, forKey: "data")
coder.encode(serverHash, forKey: "serverHash")
coder.encode(openGroupMessageServerID, forKey: "openGroupMessageServerID")
coder.encode(openGroupID, forKey: "openGroupID")
coder.encode(isBackgroundPoll, forKey: "isBackgroundPoll")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
// MARK: Running
public func execute() {
let _: Promise<Void> = execute()
}
public func execute() -> Promise<Void> {
if let id = id { // Can be nil (e.g. when background polling)
JobQueue.currentlyExecutingJobs.insert(id)
}
let (promise, seal) = Promise<Void>.pending()
GRDBStorage.shared.writeAsync(
updates: { db in
SNMessagingKitConfiguration.shared.storage.write(with: { transaction in // Intentionally capture self
do {
let isRetry = (self.failureCount != 0)
let (message, proto) = try MessageReceiver.parse(db, self.data, openGroupMessageServerID: self.openGroupMessageServerID, isRetry: isRetry, using: transaction)
message.serverHash = self.serverHash
try MessageReceiver.handle(db, message, associatedWithProto: proto, openGroupID: self.openGroupID, isBackgroundPoll: self.isBackgroundPoll, using: transaction)
self.handleSuccess()
seal.fulfill(())
} catch {
if let error = error as? MessageReceiver.Error, !error.isRetryable {
SNLog("Message receive job permanently failed due to error: \(error).")
self.handlePermanentFailure(error: error)
} else {
SNLog("Couldn't receive message due to error: \(error).")
self.handleFailure(error: error)
}
seal.fulfill(()) // The promise is just used to keep track of when we're done
}
}, completion: { })
},
completion: { _, result in
switch result {
case .failure(let error): self.handleFailure(error: error)
default: break
}
}
)
return promise
}
private func handleSuccess() {
delegate?.handleJobSucceeded(self)
}
private func handlePermanentFailure(error: Error) {
delegate?.handleJobFailedPermanently(self, with: error)
}
private func handleFailure(error: Error) {
delegate?.handleJobFailed(self, with: error)
}
}

View File

@ -1,144 +0,0 @@
import SessionUtilitiesKit
import SessionSnodeKit
@objc(SNMessageSendJob)
public final class MessageSendJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public let message: Message
public let destination: Message.Destination
public var delegate: JobDelegate?
public var id: String?
public var failureCount: UInt = 0
// MARK: Settings
public class var collection: String { return "MessageSendJobCollection" }
public static let maxFailureCount: UInt = 10
// MARK: Initialization
@objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) }
@objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) }
public init(message: Message, destination: Message.Destination) {
self.message = message
self.destination = destination
}
// MARK: Coding
public init?(coder: NSCoder) {
guard let message = coder.decodeObject(forKey: "message") as! Message?,
var rawDestination = coder.decodeObject(forKey: "destination") as! String?,
let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
self.message = message
if rawDestination.removePrefix("contact(") {
guard rawDestination.removeSuffix(")") else { return nil }
let publicKey = rawDestination
destination = .contact(publicKey: publicKey)
} else if rawDestination.removePrefix("closedGroup(") {
guard rawDestination.removeSuffix(")") else { return nil }
let groupPublicKey = rawDestination
destination = .closedGroup(groupPublicKey: groupPublicKey)
} else if rawDestination.removePrefix("openGroup(") {
guard rawDestination.removeSuffix(")") else { return nil }
let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard components.count == 2, let channel = UInt64(components[0]) else { return nil }
let server = components[1]
destination = .openGroup(channel: channel, server: server)
} else if rawDestination.removePrefix("openGroupV2(") {
guard rawDestination.removeSuffix(")") else { return nil }
let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
guard components.count == 2 else { return nil }
let room = components[0]
let server = components[1]
destination = .openGroupV2(room: room, server: server)
} else {
return nil
}
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
}
public func encode(with coder: NSCoder) {
coder.encode(message, forKey: "message")
switch destination {
case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination")
case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination")
case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination")
case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination")
}
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
// MARK: Running
public func execute() {
if let id = id {
JobQueue.currentlyExecutingJobs.insert(id)
}
let storage = SNMessagingKitConfiguration.shared.storage
if let message = message as? VisibleMessage {
guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted
let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream }
let attachmentsToUpload = attachments.filter { !$0.isUploaded }
attachmentsToUpload.forEach { attachment in
if storage.getAttachmentUploadJob(for: attachment.uniqueId!) != nil {
// Wait for it to finish
} else {
let job = AttachmentUploadJob(attachmentID: attachment.uniqueId!, threadID: message.threadID!, message: message, messageSendJobID: id!)
storage.write(with: { transaction in
JobQueue.shared.add(job, using: transaction)
}, completion: { })
}
}
if !attachmentsToUpload.isEmpty { return } // Wait for all attachments to upload before continuing
}
storage.write(with: { transaction in // Intentionally capture self
MessageSender.send(self.message, to: self.destination, using: transaction).done(on: DispatchQueue.global(qos: .userInitiated)) {
self.handleSuccess()
}.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
SNLog("Couldn't send message due to error: \(error).")
if let error = error as? MessageSender.Error, !error.isRetryable {
self.handlePermanentFailure(error: error)
} else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error,
statusCode == 429 { // Rate limited
self.handlePermanentFailure(error: error)
} else {
self.handleFailure(error: error)
}
}
}, completion: { })
}
private func handleSuccess() {
delegate?.handleJobSucceeded(self)
}
private func handlePermanentFailure(error: Error) {
delegate?.handleJobFailedPermanently(self, with: error)
}
private func handleFailure(error: Error) {
SNLog("Failed to send \(type(of: message)).")
if let message = message as? VisibleMessage {
guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted
}
delegate?.handleJobFailed(self, with: error)
}
}
// MARK: Convenience
private extension String {
@discardableResult
mutating func removePrefix<T : StringProtocol>(_ prefix: T) -> Bool {
guard hasPrefix(prefix) else { return false }
removeFirst(prefix.count)
return true
}
@discardableResult
mutating func removeSuffix<T : StringProtocol>(_ suffix: T) -> Bool {
guard hasSuffix(suffix) else { return false }
removeLast(suffix.count)
return true
}
}

View File

@ -1,69 +0,0 @@
import PromiseKit
import SessionSnodeKit
import SessionUtilitiesKit
public final class NotifyPNServerJob : NSObject, Job, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public let message: SnodeMessage
public var delegate: JobDelegate?
public var id: String?
public var failureCount: UInt = 0
// MARK: Settings
public class var collection: String { return "NotifyPNServerJobCollection" }
public static let maxFailureCount: UInt = 20
// MARK: Initialization
init(message: SnodeMessage) {
self.message = message
}
// MARK: Coding
public init?(coder: NSCoder) {
guard let message = coder.decodeObject(forKey: "message") as! SnodeMessage?,
let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
self.message = message
self.id = id
self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
}
public func encode(with coder: NSCoder) {
coder.encode(message, forKey: "message")
coder.encode(id, forKey: "id")
coder.encode(failureCount, forKey: "failureCount")
}
// MARK: Running
public func execute() {
let _: Promise<Void> = execute()
}
public func execute() -> Promise<Void> {
if let id = id {
JobQueue.currentlyExecutingJobs.insert(id)
}
let server = PushNotificationAPI.server
let parameters = [ "data" : message.data.description, "send_to" : message.recipient ]
let url = URL(string: "\(server)/notify")!
let request = TSRequest(url: url, method: "POST", parameters: parameters)
request.allHTTPHeaderFields = [ "Content-Type" : "application/json" ]
let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) {
OnionRequestAPI.sendOnionRequest(request, to: server, target: "/loki/v2/lsrpc", using: PushNotificationAPI.serverPublicKey).map { _ in }
}
let _ = promise.done(on: DispatchQueue.global()) { // Intentionally capture self
self.handleSuccess()
}
promise.catch(on: DispatchQueue.global()) { error in
self.handleFailure(error: error)
}
return promise
}
private func handleSuccess() {
delegate?.handleJobSucceeded(self)
}
private func handleFailure(error: Error) {
delegate?.handleJobFailed(self, with: error)
}
}

View File

@ -0,0 +1,100 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public enum DisappearingMessagesJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
// The 'backgroundTask' gets captured and cleared within the 'completion' block
let timestampNowMs: TimeInterval = (Date().timeIntervalSince1970 * 1000)
var backgroundTask: OWSBackgroundTask? = OWSBackgroundTask(label: #function)
let updatedJob: Job? = GRDBStorage.shared.write { db in
_ = try Interaction
.filter(Interaction.Columns.expiresStartedAtMs != nil)
.filter(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) <= \(timestampNowMs)")
.deleteAll(db)
// Update the next run timestamp for the DisappearingMessagesJob
return updateNextRunIfNeeded(db)
}
success(updatedJob ?? job, false)
// The 'if' is only there to prevent the "variable never read" warning from showing
if backgroundTask != nil { backgroundTask = nil }
}
}
// MARK: - Convenience
public extension DisappearingMessagesJob {
@discardableResult static func updateNextRunIfNeeded(_ db: Database) -> Job? {
// Don't schedule run when inactive or not in main app
var isMainAppActive = false
if let sharedUserDefaults = UserDefaults(suiteName: "group.com.loki-project.loki-messenger") {
isMainAppActive = sharedUserDefaults[.isMainAppActive]
}
guard isMainAppActive else { return nil }
// If there is another expiring message then update the job to run 1 second after it's meant to expire
let nextExpirationTimestampMs: Double? = try? Double
.fetchOne(
db,
Interaction
.select(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000)")
.order(sql: "(\(Interaction.Columns.expiresStartedAtMs) + (\(Interaction.Columns.expiresInSeconds) * 1000) asc")
)
guard let nextExpirationTimestampMs: Double = nextExpirationTimestampMs else { return nil }
return try? Job
.filter(Job.Columns.variant == Job.Variant.disappearingMessages)
.fetchOne(db)?
.with(nextRunTimestamp: ((nextExpirationTimestampMs / 1000) + 1))
.saved(db)
}
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interactionIds: [Int64], startedAtMs: Double) -> Bool {
// Update the expiring messages expiresStartedAtMs value
let changeCount: Int? = try? Interaction
.filter(interactionIds.contains(Interaction.Columns.id))
.filter(Interaction.Columns.expiresInSeconds != nil && Interaction.Columns.expiresStartedAtMs == nil)
.updateAll(db, Interaction.Columns.expiresStartedAtMs.set(to: startedAtMs))
// If there were no changes then none of the provided `interactionIds` are expiring messages
guard (changeCount ?? 0) > 0 else { return false }
return (updateNextRunIfNeeded(db) != nil)
}
@discardableResult static func updateNextRunIfNeeded(_ db: Database, interaction: Interaction, startedAtMs: Double) -> Bool {
guard interaction.isExpiringMessage else { return false }
// Don't clobber if multiple actions simultaneously triggered expiration
guard interaction.expiresStartedAtMs == nil || (interaction.expiresStartedAtMs ?? 0) > startedAtMs else {
return false
}
do {
guard let interactionId: Int64 = try? (interaction.id ?? interaction.inserted(db).id) else {
throw GRDBStorageError.objectNotFound
}
return updateNextRunIfNeeded(db, interactionIds: [interactionId], startedAtMs: startedAtMs)
}
catch {
SNLog("Failed to update the expiring messages timer on an interaction")
return false
}
}
}

View File

@ -0,0 +1,29 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
public enum FailedAttachmentDownloadsJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
// Update all 'sending' message states to 'failed'
GRDBStorage.shared.write { db in
let changeCount: Int = try Attachment
.filter(Attachment.Columns.state == Attachment.State.downloading)
.updateAll(db, Attachment.Columns.state.set(to: Attachment.State.failed))
Logger.debug("Marked \(changeCount) attachments as failed")
}
success(job, false)
}
}

View File

@ -0,0 +1,29 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SignalCoreKit
import SessionUtilitiesKit
public enum FailedMessagesJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
// Update all 'sending' message states to 'failed'
GRDBStorage.shared.write { db in
let changeCount: Int = try RecipientState
.filter(RecipientState.Columns.state == RecipientState.State.sending)
.updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.failed))
Logger.debug("Marked \(changeCount) messages as failed")
}
success(job, false)
}
}

View File

@ -0,0 +1,74 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PromiseKit
import SessionUtilitiesKit
public enum MessageReceiveJob: JobExecutor {
public static var maxFailureCount: UInt = 10
public static var requiresThreadId: Bool = true
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
guard
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
var processingError: Error?
GRDBStorage.shared.write { db in
do {
let isRetry: Bool = (job.failureCount > 0)
let (message, proto) = try MessageReceiver.parse(
db,
data: details.data,
isRetry: isRetry
)
message.serverHash = details.serverHash
try MessageReceiver.handle(
db,
message: message,
associatedWithProto: proto,
openGroupId: nil,
isBackgroundPoll: details.isBackgroundPoll
)
}
catch {
processingError = error
}
}
// Handle the result
switch processingError {
case let error as MessageReceiverError where !error.isRetryable:
SNLog("Message receive job permanently failed due to error: \(error)")
failure(job, error, true)
case .some(let error):
SNLog("Couldn't receive message due to error: \(error)")
failure(job, error, true)
case .none:
success(job, false)
}
}
}
// MARK: - MessageReceiveJob.Details
extension MessageReceiveJob {
public struct Details: Codable {
public let data: Data
public let serverHash: String?
public let isBackgroundPoll: Bool
}
}

View File

@ -0,0 +1,350 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PromiseKit
import SignalCoreKit
import SessionUtilitiesKit
import SessionSnodeKit
public enum MessageSendJob: JobExecutor {
public static var maxFailureCount: UInt = 10
public static var requiresThreadId: Bool = true
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
guard
let jobId: Int64 = job.id, // Need the 'job.id' in order to execute a MessageSendJob
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
if details.message is VisibleMessage {
guard
let interactionId: Int64 = details.interactionId,
let threadId: String = job.threadId,
let interaction: Interaction = GRDBStorage.shared.read({ db in try Interaction.fetchOne(db, id: interactionId) })
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
var shouldDeferJob: Bool = false
GRDBStorage.shared.read { db in
// Fetch all associated attachments
let attachments: [Attachment] = try interaction.attachments.fetchAll(db)
// Create jobs for any pending attachment jobs and insert them into the
// queue before the current job (this will mean the current job will re-run
// after these inserted jobs complete)
let pendingAttachments: [Attachment] = attachments.filter { $0.state == .pending }
pendingAttachments
.forEach { attachment in
JobRunner.insert(
db,
job: Job(
variant: .attachmentUpload,
behaviour: .runOnce,
threadId: job.threadId,
details: AttachmentUploadJob.Details(
threadId: threadId,
attachmentId: attachment.id,
messageSendJobId: jobId
)
),
before: job
)
}
// If there were pending or uploading attachments then stop here (we want to
// upload them first and then re-run this send job - the 'JobRunner.insert'
// method will take care of this)
shouldDeferJob = (
!pendingAttachments.isEmpty ||
attachments.contains(where: { $0.state == .uploading })
)
}
// Only continue if we don't want to defer the job
guard !shouldDeferJob else {
deferred(job)
return
}
}
// Perform the actual message sending
GRDBStorage.shared.write { db -> Promise<Void> in
try MessageSender.send(
db,
message: details.message,
to: details.destination,
interactionId: details.interactionId
)
}
.done2 { _ in success(job, false) }
.catch2 { error in
SNLog("Couldn't send message due to error: \(error).")
switch error {
case let senderError as MessageSenderError where !senderError.isRetryable:
failure(job, error, true)
case OnionRequestAPI.Error.httpRequestFailedAtDestination(let statusCode, _, _) where statusCode == 429: // Rate limited
failure(job, error, true)
default:
SNLog("Failed to send \(type(of: details.message)).")
if details.message is VisibleMessage {
guard
let interactionId: Int64 = details.interactionId,
GRDBStorage.shared.read({ db in try Interaction.exists(db, id: interactionId) }) == true
else {
// The message has been deleted so permanently fail the job
failure(job, error, true)
return
}
}
failure(job, error, false)
}
}
}
}
// MARK: - MessageSendJob.Details
extension MessageSendJob {
public struct Details: Codable {
// Note: This approach is less than ideal (since it needs to be manually maintained) but
// I couldn't think of an easy way to support a generic decoded type for the 'message'
// value in the database while using Codable
private static let supportedMessageTypes: [String: Message.Type] = [
"VisibleMessage": VisibleMessage.self,
"ReadReceipt": ReadReceipt.self,
"TypingIndicator": TypingIndicator.self,
"ClosedGroupControlMessage": ClosedGroupControlMessage.self,
"DataExtractionNotification": DataExtractionNotification.self,
"ExpirationTimerUpdate": ExpirationTimerUpdate.self,
"ConfigurationMessage": ConfigurationMessage.self,
"UnsendRequest": UnsendRequest.self,
"MessageRequestResponse": MessageRequestResponse.self
]
private enum CodingKeys: String, CodingKey {
case interactionId
case destination
case messageType
case message
}
public let interactionId: Int64?
public let destination: Message.Destination
public let message: Message
// MARK: - Initialization
public init(
interactionId: Int64? = nil,
destination: Message.Destination,
message: Message
) {
self.interactionId = interactionId
self.destination = destination
self.message = message
}
public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
guard let messageType: String = try? container.decode(String.self, forKey: .messageType) else {
Logger.error("Unable to decode messageSend job due to missing messageType")
throw GRDBStorageError.decodingFailed
}
/// Note: This **MUST** be a `Codable.Type` rather than a `Message.Type` otherwise the decoding will result
/// in a `Message` object being returned rather than the desired subclass
guard let MessageType: Codable.Type = MessageSendJob.Details.supportedMessageTypes[messageType] else {
Logger.error("Unable to decode messageSend job due to unsupported messageType")
throw GRDBStorageError.decodingFailed
}
guard let message: Message = try MessageType.decoded(with: container, forKey: .message) as? Message else {
Logger.error("Unable to decode messageSend job due to message conversion issue")
throw GRDBStorageError.decodingFailed
}
self = Details(
interactionId: try? container.decode(Int64.self, forKey: .interactionId),
destination: try container.decode(Message.Destination.self, forKey: .destination),
message: message
)
}
public func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
let messageType: Codable.Type = type(of: message)
let maybeMessageTypeString: String? = MessageSendJob.Details.supportedMessageTypes
.first(where: { _, type in messageType == type })?
.key
guard let messageTypeString: String = maybeMessageTypeString else {
Logger.error("Unable to encode messageSend job due to unsupported messageType")
throw GRDBStorageError.objectNotFound
}
try container.encodeIfPresent(interactionId, forKey: .interactionId)
try container.encode(destination, forKey: .destination)
try container.encode(messageTypeString, forKey: .messageType)
try container.encode(message, forKey: .message)
}
}
}
// public let message: Message
// public let destination: Message.Destination
// public var delegate: JobDelegate?
// public var id: String?
// public var failureCount: UInt = 0
//
// // MARK: Settings
// public class var collection: String { return "MessageSendJobCollection" }
// public static let maxFailureCount: UInt = 10
//
// // MARK: Initialization
// @objc public convenience init(message: Message, publicKey: String) { self.init(message: message, destination: .contact(publicKey: publicKey)) }
// @objc public convenience init(message: Message, groupPublicKey: String) { self.init(message: message, destination: .closedGroup(groupPublicKey: groupPublicKey)) }
//
// public init(message: Message, destination: Message.Destination) {
// self.message = message
// self.destination = destination
// }
//
// // MARK: Coding
// public init?(coder: NSCoder) {
// guard let message = coder.decodeObject(forKey: "message") as! Message?,
// var rawDestination = coder.decodeObject(forKey: "destination") as! String?,
// let id = coder.decodeObject(forKey: "id") as! String? else { return nil }
// self.message = message
// if rawDestination.removePrefix("contact(") {
// guard rawDestination.removeSuffix(")") else { return nil }
// let publicKey = rawDestination
// destination = .contact(publicKey: publicKey)
// } else if rawDestination.removePrefix("closedGroup(") {
// guard rawDestination.removeSuffix(")") else { return nil }
// let groupPublicKey = rawDestination
// destination = .closedGroup(groupPublicKey: groupPublicKey)
// } else if rawDestination.removePrefix("openGroup(") {
// guard rawDestination.removeSuffix(")") else { return nil }
// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
// guard components.count == 2, let channel = UInt64(components[0]) else { return nil }
// let server = components[1]
// destination = .openGroup(channel: channel, server: server)
// } else if rawDestination.removePrefix("openGroupV2(") {
// guard rawDestination.removeSuffix(")") else { return nil }
// let components = rawDestination.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
// guard components.count == 2 else { return nil }
// let room = components[0]
// let server = components[1]
// destination = .openGroupV2(room: room, server: server)
// } else {
// return nil
// }
// self.id = id
// self.failureCount = coder.decodeObject(forKey: "failureCount") as! UInt? ?? 0
// }
//
// public func encode(with coder: NSCoder) {
// coder.encode(message, forKey: "message")
// switch destination {
// case .contact(let publicKey): coder.encode("contact(\(publicKey))", forKey: "destination")
// case .closedGroup(let groupPublicKey): coder.encode("closedGroup(\(groupPublicKey))", forKey: "destination")
// case .openGroup(let channel, let server): coder.encode("openGroup(\(channel), \(server))", forKey: "destination")
// case .openGroupV2(let room, let server): coder.encode("openGroupV2(\(room), \(server))", forKey: "destination")
// }
// coder.encode(id, forKey: "id")
// coder.encode(failureCount, forKey: "failureCount")
// }
//
// // MARK: Running
// public func execute() {
// if let id = id {
// JobQueue.currentlyExecutingJobs.insert(id)
// }
// let storage = SNMessagingKitConfiguration.shared.storage
// if let message = message as? VisibleMessage {
// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted
// let attachments = message.attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0) as? TSAttachmentStream }
// let attachmentsToUpload = attachments.filter { !$0.isUploaded }
// attachmentsToUpload.forEach { attachment in
// if storage.getAttachmentUploadJob(for: attachment.uniqueId!) != nil {
// // Wait for it to finish
// } else {
// let job = AttachmentUploadJob(attachmentID: attachment.uniqueId!, threadID: message.threadID!, message: message, messageSendJobID: id!)
// storage.write(with: { transaction in
// JobQueue.shared.add(job, using: transaction)
// }, completion: { })
// }
// }
// if !attachmentsToUpload.isEmpty { return } // Wait for all attachments to upload before continuing
// }
// storage.write(with: { transaction in // Intentionally capture self
// MessageSender.send(self.message, to: self.destination, using: transaction).done(on: DispatchQueue.global(qos: .userInitiated)) {
// self.handleSuccess()
// }.catch(on: DispatchQueue.global(qos: .userInitiated)) { error in
// SNLog("Couldn't send message due to error: \(error).")
// if let error = error as? MessageSender.Error, !error.isRetryable {
// self.handlePermanentFailure(error: error)
// } else if let error = error as? OnionRequestAPI.Error, case .httpRequestFailedAtDestination(let statusCode, _, _) = error,
// statusCode == 429 { // Rate limited
// self.handlePermanentFailure(error: error)
// } else {
// self.handleFailure(error: error)
// }
// }
// }, completion: { })
// }
//
// private func handleSuccess() {
// delegate?.handleJobSucceeded(self)
// }
//
// private func handlePermanentFailure(error: Error) {
// delegate?.handleJobFailedPermanently(self, with: error)
// }
//
// private func handleFailure(error: Error) {
// SNLog("Failed to send \(type(of: message)).")
// if let message = message as? VisibleMessage {
// guard TSOutgoingMessage.find(withTimestamp: message.sentTimestamp!) != nil else { return } // The message has been deleted
// }
// delegate?.handleJobFailed(self, with: error)
// }
//}
//
//// MARK: Convenience
//private extension String {
//
// @discardableResult
// mutating func removePrefix<T : StringProtocol>(_ prefix: T) -> Bool {
// guard hasPrefix(prefix) else { return false }
// removeFirst(prefix.count)
// return true
// }
//
// @discardableResult
// mutating func removeSuffix<T : StringProtocol>(_ suffix: T) -> Bool {
// guard hasSuffix(suffix) else { return false }
// removeLast(suffix.count)
// return true
// }
//}
//

View File

@ -0,0 +1,66 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PromiseKit
import SessionSnodeKit
import SessionUtilitiesKit
public enum NotifyPushServerJob: JobExecutor {
public static var maxFailureCount: UInt = 20
public static var requiresThreadId: Bool = false
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
let server: String = PushNotificationAPI.server
guard
let url: URL = URL(string: "\(server)/notify"),
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
let parameters: JSON = [
"data": details.message.data.description,
"send_to": details.message.recipient
]
let request = TSRequest(url: url, method: "POST", parameters: parameters)
request.allHTTPHeaderFields = [
"Content-Type": "application/json"
]
let promise = attempt(maxRetryCount: 4, recoveringOn: DispatchQueue.global()) {
OnionRequestAPI
.sendOnionRequest(
request,
to: server,
target: "/loki/v2/lsrpc",
using: PushNotificationAPI.serverPublicKey
)
.map { _ in }
}
.done { _ in
success(job, false)
}
promise.catch { error in
failure(job, error, false)
}
promise.retainUntilComplete()
}
}
// MARK: - NotifyPushServerJob.Details
extension NotifyPushServerJob {
public struct Details: Codable {
public let message: SnodeMessage
}
}

View File

@ -0,0 +1,136 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import SessionUtilitiesKit
public enum SendReadReceiptsJob: JobExecutor {
public static let maxFailureCount: UInt = 0
public static let requiresThreadId: Bool = false
private static let minRunFrequency: TimeInterval = 3
public static func run(
_ job: Job,
success: @escaping (Job, Bool) -> (),
failure: @escaping (Job, Error?, Bool) -> (),
deferred: @escaping (Job) -> ()
) {
guard
let threadId: String = job.threadId,
let detailsData: Data = job.details,
let details: Details = try? JSONDecoder().decode(Details.self, from: detailsData)
else {
failure(job, JobRunnerError.missingRequiredDetails, false)
return
}
// If there are no timestampMs values then the job can just complete (next time
// something is marked as read we want to try and run immediately so don't scuedule
// another run in this case)
guard !details.timestampMsValues.isEmpty else {
success(job, true)
return
}
GRDBStorage.shared
.write { db in
try MessageSender.send(
db,
message: ReadReceipt(
timestamps: details.timestampMsValues.map { UInt64($0) }
),
to: details.destination,
interactionId: nil
)
}
.done {
// When we complete the 'SendReadReceiptsJob' we want to immediately schedule
// another one for the same thread but with a 'nextRunTimestamp' set to the
// 'minRunFrequency' value to throttle the read receipt requests
GRDBStorage.shared.write { db in
_ = try createOrUpdateIfNeeded(db, threadId: threadId, interactionIds: [])?
.with(nextRunTimestamp: (Date().timeIntervalSince1970 + minRunFrequency))
.saved(db)
}
success(job, false)
}
.catch { error in failure(job, error, false) }
.retainUntilComplete()
}
}
// MARK: - SendReadReceiptsJob.Details
extension SendReadReceiptsJob {
public struct Details: Codable {
public let destination: Message.Destination
public let timestampMsValues: Set<Int64>
}
}
// MARK: - Convenience
public extension SendReadReceiptsJob {
@discardableResult static func createOrUpdateIfNeeded(_ db: Database, threadId: String, interactionIds: [Int64]) -> Job? {
guard db[.areReadReceiptsEnabled] == true else { return nil }
// Retrieve the timestampMs values for the specified interactions
let maybeTimestampMsValues: [Int64]? = try? Int64.fetchAll(
db,
Interaction
.select(Interaction.Columns.timestampMs)
.filter(interactionIds.contains(Interaction.Columns.id))
// Only `standardIncoming` incoming interactions should have read receipts sent
.filter(Interaction.Columns.variant == Interaction.Variant.standardIncoming)
.joining(
// Don't send read receipts in group threads
required: Interaction.thread
.filter(SessionThread.Columns.variant != SessionThread.Variant.closedGroup)
.filter(SessionThread.Columns.variant != SessionThread.Variant.openGroup)
)
.distinct()
)
// If there are no timestamp values then do nothing
guard let timestampMsValues: [Int64] = maybeTimestampMsValues else { return nil }
// Try to get an existing job (if there is one that's not running)
if
let existingJob: Job = try? Job
.filter(Job.Columns.variant == Job.Variant.sendReadReceipts)
.filter(Job.Columns.threadId == threadId)
.fetchOne(db),
!JobRunner.isCurrentlyRunning(existingJob),
let existingDetailsData: Data = existingJob.details,
let existingDetails: Details = try? JSONDecoder().decode(Details.self, from: existingDetailsData)
{
let maybeUpdatedJob: Job? = existingJob
.with(
details: Details(
destination: existingDetails.destination,
timestampMsValues: existingDetails.timestampMsValues
.union(timestampMsValues)
)
)
guard let updatedJob: Job = maybeUpdatedJob else { return nil }
return try? updatedJob
.saved(db)
}
// Otherwise create a new job
return Job(
variant: .sendReadReceipts,
behaviour: .recurring,
threadId: threadId,
details: Details(
destination: .contact(publicKey: threadId),
timestampMsValues: timestampMsValues.asSet()
)
)
}
}

View File

@ -1,11 +1,16 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
import Curve25519Kit
import SessionUtilitiesKit
public final class ClosedGroupControlMessage : ControlMessage {
private enum CodingKeys: String, CodingKey {
case kind
}
public var kind: Kind?
public override var ttl: UInt64 {
@ -18,7 +23,19 @@ public final class ClosedGroupControlMessage : ControlMessage {
public override var isSelfSendValid: Bool { true }
// MARK: Kind
public enum Kind : CustomStringConvertible {
public enum Kind: CustomStringConvertible, Codable {
private enum CodingKeys: String, CodingKey {
case description
case publicKey
case name
case encryptionPublicKey
case encryptionSecretKey
case members
case admins
case expirationTimer
case wrappers
}
case new(publicKey: Data, name: String, encryptionKeyPair: Box.KeyPair, members: [Data], admins: [Data], expirationTimer: UInt32)
/// An encryption key pair encrypted for each member individually.
///
@ -41,11 +58,101 @@ public final class ClosedGroupControlMessage : ControlMessage {
case .encryptionKeyPairRequest: return "encryptionKeyPairRequest"
}
}
public init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
// Compare the descriptions to find the appropriate case
let description: String = try container.decode(String.self, forKey: .description)
let newDescription: String = Kind.new(
publicKey: Data(),
name: "",
encryptionKeyPair: Box.KeyPair(publicKey: [], secretKey: []),
members: [],
admins: [],
expirationTimer: 0
).description
switch description {
case newDescription:
self = .new(
publicKey: try container.decode(Data.self, forKey: .publicKey),
name: try container.decode(String.self, forKey: .name),
encryptionKeyPair: Box.KeyPair(
publicKey: try container.decode([UInt8].self, forKey: .encryptionPublicKey),
secretKey: try container.decode([UInt8].self, forKey: .encryptionSecretKey)
),
members: try container.decode([Data].self, forKey: .members),
admins: try container.decode([Data].self, forKey: .admins),
expirationTimer: try container.decode(UInt32.self, forKey: .expirationTimer)
)
case Kind.encryptionKeyPair(publicKey: nil, wrappers: []).description:
self = .encryptionKeyPair(
publicKey: try? container.decode(Data.self, forKey: .publicKey),
wrappers: try container.decode([ClosedGroupControlMessage.KeyPairWrapper].self, forKey: .wrappers)
)
case Kind.nameChange(name: "").description:
self = .nameChange(
name: try container.decode(String.self, forKey: .name)
)
case Kind.membersAdded(members: []).description:
self = .membersAdded(
members: try container.decode([Data].self, forKey: .members)
)
case Kind.membersRemoved(members: []).description:
self = .membersRemoved(
members: try container.decode([Data].self, forKey: .members)
)
case Kind.memberLeft.description:
self = .memberLeft
case Kind.encryptionKeyPairRequest.description:
self = .encryptionKeyPairRequest
default: fatalError("Invalid case when trying to decode ClosedGroupControlMessage.Kind")
}
}
public func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encode(description, forKey: .description)
// Note: If you modify the below make sure to update the above 'init(from:)' method
switch self {
case .new(let publicKey, let name, let encryptionKeyPair, let members, let admins, let expirationTimer):
try container.encode(publicKey, forKey: .publicKey)
try container.encode(name, forKey: .name)
try container.encode(encryptionKeyPair.publicKey, forKey: .encryptionPublicKey)
try container.encode(encryptionKeyPair.secretKey, forKey: .encryptionSecretKey)
try container.encode(members, forKey: .members)
try container.encode(admins, forKey: .admins)
try container.encode(expirationTimer, forKey: .expirationTimer)
case .encryptionKeyPair(let publicKey, let wrappers):
try container.encode(publicKey, forKey: .publicKey)
try container.encode(wrappers, forKey: .wrappers)
case .nameChange(let name):
try container.encode(name, forKey: .name)
case .membersAdded(let members), .membersRemoved(let members):
try container.encode(members, forKey: .members)
case .memberLeft: break // Only 'description'
case .encryptionKeyPairRequest: break // Only 'description'
}
}
}
// MARK: Key Pair Wrapper
@objc(SNKeyPairWrapper)
public final class KeyPairWrapper : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public final class KeyPairWrapper: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public var publicKey: String?
public var encryptedKeyPair: Data?
@ -143,7 +250,7 @@ public final class ClosedGroupControlMessage : ControlMessage {
default: return nil
}
}
public override func encode(with coder: NSCoder) {
super.encode(with: coder)
guard let kind = kind else { return }
@ -175,6 +282,24 @@ public final class ClosedGroupControlMessage : ControlMessage {
coder.encode("encryptionKeyPairRequest", forKey: "kind")
}
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
kind = try container.decode(Kind.self, forKey: .kind)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encode(kind, forKey: .kind)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ClosedGroupControlMessage? {
@ -207,7 +332,7 @@ public final class ClosedGroupControlMessage : ControlMessage {
return ClosedGroupControlMessage(kind: kind)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let kind = kind else {
SNLog("Couldn't construct closed group update proto from: \(self).")
return nil
@ -253,7 +378,7 @@ public final class ClosedGroupControlMessage : ControlMessage {
let dataMessageProto = SNProtoDataMessage.builder()
dataMessageProto.setClosedGroupControlMessage(try closedGroupControlMessage.build())
// Group context
try setGroupContextIfNeeded(on: dataMessageProto, using: transaction)
try setGroupContextIfNeeded(db, on: dataMessageProto)
contentProto.setDataMessage(try dataMessageProto.build())
return try contentProto.build()
} catch {
@ -271,3 +396,65 @@ public final class ClosedGroupControlMessage : ControlMessage {
"""
}
}
// MARK: - Convenience
public extension ClosedGroupControlMessage.Kind {
func infoMessage(_ db: Database, sender: String) throws -> String? {
switch self {
case .nameChange(let name):
return String(format: "GROUP_TITLE_CHANGED".localized(), name)
case .membersAdded(let membersAsData):
let addedMemberNames: [String] = try Profile
.fetchAll(db, ids: membersAsData.map { $0.toHexString() })
.map { $0.displayName() }
return String(
format: "GROUP_MEMBER_JOINED".localized(),
addedMemberNames.joined(separator: ", ")
)
case .membersRemoved(let membersAsData):
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let memberIds: Set<String> = membersAsData
.map { $0.toHexString() }
.asSet()
var infoMessage: String = ""
if !memberIds.removing(userPublicKey).isEmpty {
let removedMemberNames: [String] = try Profile
.fetchAll(db, ids: memberIds.removing(userPublicKey))
.map { $0.displayName() }
let format: String = (removedMemberNames.count > 1 ?
"GROUP_MEMBERS_REMOVED".localized() :
"GROUP_MEMBER_REMOVED".localized()
)
infoMessage = infoMessage.appending(
String(format: format, removedMemberNames.joined(separator: ", "))
)
}
if memberIds.contains(userPublicKey) {
infoMessage = infoMessage.appending("YOU_WERE_REMOVED".localized())
}
return infoMessage
case .memberLeft:
let userPublicKey: String = getUserHexEncodedPublicKey(db)
guard sender != userPublicKey else { return "GROUP_YOU_LEFT".localized() }
if let displayName: String = Profile.displayNameNoFallback(db, id: sender) {
return String(format: "GROUP_MEMBER_LEFT".localized(), displayName)
}
return "GROUP_UPDATED".localized()
default: return nil
}
}
}

View File

@ -6,7 +6,7 @@ import SessionUtilitiesKit
extension ConfigurationMessage {
public static func getCurrent(_ db: Database) throws -> ConfigurationMessage? {
public static func getCurrent(_ db: Database) throws -> ConfigurationMessage {
let profile: Profile = Profile.fetchOrCreateCurrentUser(db)
let displayName: String = profile.name

View File

@ -1,11 +1,21 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Curve25519Kit
import SessionUtilitiesKit
@objc(SNConfigurationMessage)
public final class ConfigurationMessage : ControlMessage {
private enum CodingKeys: String, CodingKey {
case closedGroups
case openGroups
case displayName
case profilePictureURL
case profileKey
case contacts
}
public var closedGroups: Set<ClosedGroup> = []
public var openGroups: Set<String> = []
public var displayName: String?
@ -48,6 +58,34 @@ public final class ConfigurationMessage : ControlMessage {
coder.encode(profileKey, forKey: "profileKey")
coder.encode(contacts, forKey: "contacts")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
closedGroups = ((try? container.decode(Set<ClosedGroup>.self, forKey: .closedGroups)) ?? [])
openGroups = ((try? container.decode(Set<String>.self, forKey: .openGroups)) ?? [])
displayName = try? container.decode(String.self, forKey: .displayName)
profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL)
profileKey = try? container.decode(Data.self, forKey: .profileKey)
contacts = ((try? container.decode(Set<CMContact>.self, forKey: .contacts)) ?? [])
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(closedGroups, forKey: .closedGroups)
try container.encodeIfPresent(openGroups, forKey: .openGroups)
try container.encodeIfPresent(displayName, forKey: .displayName)
try container.encodeIfPresent(profilePictureURL, forKey: .profilePictureURL)
try container.encodeIfPresent(profileKey, forKey: .profileKey)
try container.encodeIfPresent(contacts, forKey: .contacts)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ConfigurationMessage? {
@ -62,7 +100,7 @@ public final class ConfigurationMessage : ControlMessage {
closedGroups: closedGroups, openGroups: openGroups, contacts: contacts)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
let configurationProto = SNProtoConfigurationMessage.builder()
if let displayName = displayName { configurationProto.setDisplayName(displayName) }
if let profilePictureURL = profilePictureURL { configurationProto.setProfilePicture(profilePictureURL) }
@ -99,7 +137,17 @@ public final class ConfigurationMessage : ControlMessage {
extension ConfigurationMessage {
@objc(SNClosedGroup)
public final class ClosedGroup : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public final class ClosedGroup: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
private enum CodingKeys: String, CodingKey {
case publicKey
case name
case encryptionKeyPublicKey
case encryptionKeySecretKey
case members
case admins
case expirationTimer
}
public let publicKey: String
public let name: String
public let encryptionKeyPair: ECKeyPair
@ -141,6 +189,34 @@ extension ConfigurationMessage {
coder.encode(admins, forKey: "admins")
coder.encode(expirationTimer, forKey: "expirationTimer")
}
// MARK: - Codable
public required init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
publicKey = try container.decode(String.self, forKey: .publicKey)
name = try container.decode(String.self, forKey: .name)
encryptionKeyPair = try ECKeyPair(
publicKeyData: try container.decode(Data.self, forKey: .encryptionKeyPublicKey),
privateKeyData: try container.decode(Data.self, forKey: .encryptionKeySecretKey)
)
members = try container.decode(Set<String>.self, forKey: .members)
admins = try container.decode(Set<String>.self, forKey: .admins)
expirationTimer = try container.decode(UInt32.self, forKey: .expirationTimer)
}
public func encode(to encoder: Encoder) throws {
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encode(publicKey, forKey: .publicKey)
try container.encode(name, forKey: .name)
try container.encode(encryptionKeyPair.publicKey, forKey: .encryptionKeyPublicKey)
try container.encode(encryptionKeyPair.privateKey, forKey: .encryptionKeySecretKey)
try container.encode(members, forKey: .members)
try container.encode(admins, forKey: .admins)
try container.encode(expirationTimer, forKey: .expirationTimer)
}
public static func fromProto(_ proto: SNProtoConfigurationMessageClosedGroup) -> ClosedGroup? {
guard let publicKey = proto.publicKey?.toHexString(),
@ -192,7 +268,21 @@ extension ConfigurationMessage {
extension ConfigurationMessage {
@objc(SNConfigurationMessageContact)
public final class CMContact : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public final class CMContact: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
private enum CodingKeys: String, CodingKey {
case publicKey
case displayName
case profilePictureURL
case profileKey
case hasIsApproved
case isApproved
case hasIsBlocked
case isBlocked
case hasDidApproveMe
case didApproveMe
}
public var publicKey: String?
public var displayName: String?
public var profilePictureURL: String?
@ -258,6 +348,24 @@ extension ConfigurationMessage {
coder.encode(hasDidApproveMe, forKey: "hasDidApproveMe")
coder.encode(didApproveMe, forKey: "didApproveMe")
}
// MARK: - Codable
public required init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
publicKey = try? container.decode(String.self, forKey: .publicKey)
displayName = try? container.decode(String.self, forKey: .displayName)
profilePictureURL = try? container.decode(String.self, forKey: .profilePictureURL)
profileKey = try? container.decode(Data.self, forKey: .profileKey)
hasIsApproved = try container.decode(Bool.self, forKey: .hasIsApproved)
isApproved = try container.decode(Bool.self, forKey: .isApproved)
hasIsBlocked = try container.decode(Bool.self, forKey: .hasIsBlocked)
isBlocked = try container.decode(Bool.self, forKey: .isBlocked)
hasDidApproveMe = try container.decode(Bool.self, forKey: .hasDidApproveMe)
didApproveMe = try container.decode(Bool.self, forKey: .didApproveMe)
}
public static func fromProto(_ proto: SNProtoConfigurationMessageContact) -> CMContact? {
let result: CMContact = CMContact(

View File

@ -1,3 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
@objc(SNControlMessage)
public class ControlMessage : Message { }
public class ControlMessage: Message { }

View File

@ -1,10 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public final class DataExtractionNotification : ControlMessage {
private enum CodingKeys: String, CodingKey {
case kind
}
public var kind: Kind?
// MARK: Kind
public enum Kind : CustomStringConvertible {
public enum Kind: CustomStringConvertible, Codable {
case screenshot
case mediaSaved(timestamp: UInt64)
@ -58,6 +66,24 @@ public final class DataExtractionNotification : ControlMessage {
coder.encode(timestamp, forKey: "timestamp")
}
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
kind = try? container.decode(Kind.self, forKey: .kind)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(kind, forKey: .kind)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> DataExtractionNotification? {
@ -72,7 +98,7 @@ public final class DataExtractionNotification : ControlMessage {
return DataExtractionNotification(kind: kind)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let kind = kind else {
SNLog("Couldn't construct data extraction notification proto from: \(self).")
return nil

View File

@ -1,7 +1,16 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNExpirationTimerUpdate)
public final class ExpirationTimerUpdate : ControlMessage {
private enum CodingKeys: String, CodingKey {
case syncTarget
case duration
}
/// In the case of a sync message, the public key of the person the message was targeted at.
///
/// - Note: `nil` if this isn't a sync message.
@ -31,24 +40,47 @@ public final class ExpirationTimerUpdate : ControlMessage {
if let syncTarget = coder.decodeObject(forKey: "syncTarget") as! String? { self.syncTarget = syncTarget }
if let duration = coder.decodeObject(forKey: "durationSeconds") as! UInt32? { self.duration = duration }
}
public override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(syncTarget, forKey: "syncTarget")
coder.encode(duration, forKey: "durationSeconds")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
syncTarget = try? container.decode(String.self, forKey: .syncTarget)
duration = try? container.decode(UInt32.self, forKey: .duration)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(syncTarget, forKey: .syncTarget)
try container.encodeIfPresent(duration, forKey: .duration)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ExpirationTimerUpdate? {
guard let dataMessageProto = proto.dataMessage else { return nil }
let isExpirationTimerUpdate = (dataMessageProto.flags & UInt32(SNProtoDataMessage.SNProtoDataMessageFlags.expirationTimerUpdate.rawValue)) != 0
guard isExpirationTimerUpdate else { return nil }
let syncTarget = dataMessageProto.syncTarget
let duration = dataMessageProto.expireTimer
return ExpirationTimerUpdate(syncTarget: syncTarget, duration: duration)
return ExpirationTimerUpdate(
syncTarget: dataMessageProto.syncTarget,
duration: dataMessageProto.expireTimer
)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let duration = duration else {
SNLog("Couldn't construct expiration timer update proto from: \(self).")
return nil
@ -59,7 +91,7 @@ public final class ExpirationTimerUpdate : ControlMessage {
if let syncTarget = syncTarget { dataMessageProto.setSyncTarget(syncTarget) }
// Group context
do {
try setGroupContextIfNeeded(on: dataMessageProto, using: transaction)
try setGroupContextIfNeeded(db, on: dataMessageProto)
} catch {
SNLog("Couldn't construct expiration timer update proto from: \(self).")
return nil

View File

@ -1,7 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNMessageRequestResponse)
public final class MessageRequestResponse: ControlMessage {
private enum CodingKeys: String, CodingKey {
case isApproved
}
public var isApproved: Bool
// MARK: - Initialization
@ -28,6 +36,24 @@ public final class MessageRequestResponse: ControlMessage {
coder.encode(isApproved, forKey: "isApproved")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
isApproved = try container.decode(Bool.self, forKey: .isApproved)
try super.init(from: decoder)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(isApproved, forKey: .isApproved)
}
// MARK: - Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> MessageRequestResponse? {
@ -38,7 +64,7 @@ public final class MessageRequestResponse: ControlMessage {
return MessageRequestResponse(isApproved: isApproved)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
let messageRequestResponseProto = SNProtoMessageRequestResponse.builder(isApproved: isApproved)
let contentProto = SNProtoContent.builder()

View File

@ -1,7 +1,15 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNReadReceipt)
public final class ReadReceipt : ControlMessage {
private enum CodingKeys: String, CodingKey {
case timestamps
}
@objc public var timestamps: [UInt64]?
// MARK: Initialization
@ -29,6 +37,24 @@ public final class ReadReceipt : ControlMessage {
super.encode(with: coder)
coder.encode(timestamps, forKey: "messageTimestamps")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
timestamps = try? container.decode([UInt64].self, forKey: .timestamps)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(timestamps, forKey: .timestamps)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> ReadReceipt? {
@ -38,7 +64,7 @@ public final class ReadReceipt : ControlMessage {
return ReadReceipt(timestamps: timestamps)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let timestamps = timestamps else {
SNLog("Couldn't construct read receipt proto from: \(self).")
return nil

View File

@ -1,13 +1,21 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNTypingIndicator)
public final class TypingIndicator : ControlMessage {
private enum CodingKeys: String, CodingKey {
case kind
}
public var kind: Kind?
public override var ttl: UInt64 { 20 * 1000 }
// MARK: Kind
public enum Kind : Int, CustomStringConvertible {
public enum Kind: Int, Codable, CustomStringConvertible {
case started, stopped
static func fromProto(_ proto: SNProtoTypingMessage.SNProtoTypingMessageAction) -> Kind {
@ -56,6 +64,24 @@ public final class TypingIndicator : ControlMessage {
super.encode(with: coder)
coder.encode(kind?.rawValue, forKey: "action")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
kind = try? container.decode(Kind.self, forKey: .kind)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(kind, forKey: .kind)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> TypingIndicator? {
@ -64,7 +90,7 @@ public final class TypingIndicator : ControlMessage {
return TypingIndicator(kind: kind)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let timestamp = sentTimestamp, let kind = kind else {
SNLog("Couldn't construct typing indicator proto from: \(self).")
return nil

View File

@ -1,7 +1,16 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNUnsendRequest)
public final class UnsendRequest: ControlMessage {
private enum CodingKeys: String, CodingKey {
case timestamp
case author
}
public var timestamp: UInt64?
public var author: String?
@ -35,6 +44,26 @@ public final class UnsendRequest: ControlMessage {
coder.encode(author, forKey: "author")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
timestamp = try? container.decode(UInt64.self, forKey: .timestamp)
author = try? container.decode(String.self, forKey: .author)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(timestamp, forKey: .timestamp)
try container.encodeIfPresent(author, forKey: .author)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> UnsendRequest? {
guard let unsendRequestProto = proto.unsendRequest else { return nil }
@ -43,7 +72,7 @@ public final class UnsendRequest: ControlMessage {
return UnsendRequest(timestamp: timestamp, author: author)
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
guard let timestamp = timestamp, let author = author else {
SNLog("Couldn't construct unsend request proto from: \(self).")
return nil

View File

@ -1,24 +1,26 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public extension Message {
enum Destination {
enum Destination: Codable {
case contact(publicKey: String)
case closedGroup(groupPublicKey: String)
case openGroup(channel: UInt64, server: String)
case openGroupV2(room: String, server: String)
static func from(_ thread: TSThread) -> Message.Destination {
if let thread = thread as? TSContactThread {
return .contact(publicKey: thread.contactSessionID())
} else if let thread = thread as? TSGroupThread, thread.isClosedGroup {
let groupID = thread.groupModel.groupId
let groupPublicKey = LKGroupUtilities.getDecodedGroupID(groupID)
return .closedGroup(groupPublicKey: groupPublicKey)
} else if let thread = thread as? TSGroupThread, thread.isOpenGroup {
let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!)!
return .openGroupV2(room: openGroupV2.room, server: openGroupV2.server)
} else {
preconditionFailure("TODO: Handle legacy closed groups.")
static func from(_ db: Database, thread: SessionThread) throws -> Message.Destination {
switch thread.variant {
case .contact: return .contact(publicKey: thread.id)
case .closedGroup: return .closedGroup(groupPublicKey: thread.id)
case .openGroup:
guard let openGroup: OpenGroup = try thread.openGroup.fetchOne(db) else {
throw GRDBStorageError.objectNotFound
}
return .openGroupV2(room: openGroup.room, server: openGroup.server)
}
}
}

View File

@ -1,7 +1,11 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
/// Abstract base class for `VisibleMessage` and `ControlMessage`.
@objc(SNMessage)
public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public class Message: NSObject, Codable, NSCoding { // NSObject/NSCoding conformance is needed for YapDatabase compatibility
public var id: String?
@objc public var threadID: String?
public var sentTimestamp: UInt64?
@ -57,14 +61,20 @@ public class Message : NSObject, NSCoding { // NSObject/NSCoding conformance is
preconditionFailure("fromProto(_:sender:) is abstract and must be overridden.")
}
public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
preconditionFailure("toProto(using:) is abstract and must be overridden.")
public func toProto(_ db: Database) -> SNProtoContent? {
preconditionFailure("toProto(_:) is abstract and must be overridden.")
}
public func setGroupContextIfNeeded(on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder, using transaction: YapDatabaseReadTransaction) throws {
guard let thread = TSThread.fetch(uniqueId: threadID!, transaction: transaction) as? TSGroupThread, thread.isClosedGroup else { return }
public func setGroupContextIfNeeded(_ db: Database, on dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder) throws {
guard
let threadId: String = threadID,
let thread: SessionThread = try? SessionThread.fetchOne(db, id: threadId),
thread.variant == .closedGroup,
let legacyGroupId: Data = "\(Legacy.closedGroupIdPrefix)\(threadId)".data(using: .utf8)
else { return }
// Android needs a group context or it'll interpret the message as a one-to-one message
let groupProto = SNProtoGroupContext.builder(id: thread.groupModel.groupId, type: .deliver)
let groupProto = SNProtoGroupContext.builder(id: legacyGroupId, type: .deliver)
dataMessage.setGroup(try groupProto.build())
}

View File

@ -1,10 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CoreGraphics
import SessionUtilitiesKit
public extension VisibleMessage {
@objc(SNAttachment)
class Attachment : NSObject, NSCoding {
class Attachment: NSObject, Codable, NSCoding {
public var fileName: String?
public var contentType: String?
public var key: Data?
@ -20,7 +23,7 @@ public extension VisibleMessage {
contentType != nil && kind != nil && size != nil && sizeInBytes != nil && url != nil
}
public enum Kind : String {
public enum Kind: String, Codable {
case voiceMessage, generic
}

View File

@ -1,11 +0,0 @@
public extension VisibleMessage {
@objc(SNMessageContact)
class Contact : NSObject, NSCoding {
public required init?(coder: NSCoder) { }
public func encode(with coder: NSCoder) { }
}
}

View File

@ -1,9 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public extension VisibleMessage {
@objc(SNLinkPreview)
class LinkPreview : NSObject, NSCoding {
class LinkPreview: NSObject, Codable, NSCoding {
public var title: String?
public var url: String?
public var attachmentID: String?
@ -38,17 +42,22 @@ public extension VisibleMessage {
preconditionFailure("Use toProto(using:) instead.")
}
public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoDataMessagePreview? {
public func toProto(_ db: Database) -> SNProtoDataMessagePreview? {
guard let url = url else {
SNLog("Couldn't construct link preview proto from: \(self).")
return nil
}
let linkPreviewProto = SNProtoDataMessagePreview.builder(url: url)
if let title = title { linkPreviewProto.setTitle(title) }
if let attachmentID = attachmentID, let stream = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) as? TSAttachmentStream,
let attachmentProto = stream.buildProto() {
if
let attachmentID = attachmentID,
let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID),
let attachmentProto = attachment.buildProto()
{
linkPreviewProto.setImage(attachmentProto)
}
do {
return try linkPreviewProto.build()
} catch {
@ -69,3 +78,15 @@ public extension VisibleMessage {
}
}
}
// MARK: - Database Type Conversion
public extension VisibleMessage.LinkPreview {
static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.LinkPreview {
return VisibleMessage.LinkPreview(
title: linkPreview.title,
url: linkPreview.url,
attachmentID: linkPreview.attachmentId
)
}
}

View File

@ -1,9 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public extension VisibleMessage {
@objc(SNOpenGroupInvitation)
class OpenGroupInvitation : NSObject, NSCoding {
class OpenGroupInvitation: NSObject, Codable, NSCoding {
public var name: String?
public var url: String?
@ -54,3 +58,16 @@ public extension VisibleMessage {
}
}
}
// MARK: - Database Type Conversion
public extension VisibleMessage.OpenGroupInvitation {
static func from(_ db: Database, linkPreview: LinkPreview) -> VisibleMessage.OpenGroupInvitation? {
guard let name: String = linkPreview.title else { return nil }
return VisibleMessage.OpenGroupInvitation(
name: name,
url: linkPreview.url
)
}
}

View File

@ -1,72 +1,75 @@
//import SessionUtilitiesKit
//
//public extension VisibleMessage {
//
// @objc(SNProfile)
// class Profile : NSObject, NSCoding {
// public var displayName: String?
// public var profileKey: Data?
// public var profilePictureURL: String?
//
// internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) {
// self.displayName = displayName
// self.profileKey = profileKey
// self.profilePictureURL = profilePictureURL
// }
//
// public required init?(coder: NSCoder) {
// if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName }
// if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey }
// if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL }
// }
//
// public func encode(with coder: NSCoder) {
// coder.encode(displayName, forKey: "displayName")
// coder.encode(profileKey, forKey: "profileKey")
// coder.encode(profilePictureURL, forKey: "profilePictureURL")
// }
//
// public static func fromProto(_ proto: SNProtoDataMessage, sessionId: String) -> Profile? {
// guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil }
// let profileKey = proto.profileKey
// let profilePictureURL = profileProto.profilePicture
// if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
// return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL)
// } else {
// return Profile(displayName: displayName)
// }
// }
//
// public func toProto() -> SNProtoDataMessage? {
// guard let displayName = displayName else {
// SNLog("Couldn't construct profile proto from: \(self).")
// return nil
// }
// let dataMessageProto = SNProtoDataMessage.builder()
// let profileProto = SNProtoDataMessageLokiProfile.builder()
// profileProto.setDisplayName(displayName)
// if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
// dataMessageProto.setProfileKey(profileKey)
// profileProto.setProfilePicture(profilePictureURL)
// }
// do {
// dataMessageProto.setProfile(try profileProto.build())
// return try dataMessageProto.build()
// } catch {
// SNLog("Couldn't construct profile proto from: \(self).")
// return nil
// }
// }
//
// // MARK: Description
// public override var description: String {
// """
// Profile(
// displayName: \(displayName ?? "null"),
// profileKey: \(profileKey?.description ?? "null"),
// profilePictureURL: \(profilePictureURL ?? "null")
// )
// """
// }
// }
//}
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SessionUtilitiesKit
public extension VisibleMessage {
@objc(SNProfile)
class Profile: NSObject, Codable, NSCoding {
public var displayName: String?
public var profileKey: Data?
public var profilePictureURL: String?
internal init(displayName: String, profileKey: Data? = nil, profilePictureURL: String? = nil) {
self.displayName = displayName
self.profileKey = profileKey
self.profilePictureURL = profilePictureURL
}
public required init?(coder: NSCoder) {
if let displayName = coder.decodeObject(forKey: "displayName") as! String? { self.displayName = displayName }
if let profileKey = coder.decodeObject(forKey: "profileKey") as! Data? { self.profileKey = profileKey }
if let profilePictureURL = coder.decodeObject(forKey: "profilePictureURL") as! String? { self.profilePictureURL = profilePictureURL }
}
public func encode(with coder: NSCoder) {
coder.encode(displayName, forKey: "displayName")
coder.encode(profileKey, forKey: "profileKey")
coder.encode(profilePictureURL, forKey: "profilePictureURL")
}
public static func fromProto(_ proto: SNProtoDataMessage) -> Profile? {
guard let profileProto = proto.profile, let displayName = profileProto.displayName else { return nil }
let profileKey = proto.profileKey
let profilePictureURL = profileProto.profilePicture
if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
return Profile(displayName: displayName, profileKey: profileKey, profilePictureURL: profilePictureURL)
} else {
return Profile(displayName: displayName)
}
}
public func toProto() -> SNProtoDataMessage? {
guard let displayName = displayName else {
SNLog("Couldn't construct profile proto from: \(self).")
return nil
}
let dataMessageProto = SNProtoDataMessage.builder()
let profileProto = SNProtoDataMessageLokiProfile.builder()
profileProto.setDisplayName(displayName)
if let profileKey = profileKey, let profilePictureURL = profilePictureURL {
dataMessageProto.setProfileKey(profileKey)
profileProto.setProfilePicture(profilePictureURL)
}
do {
dataMessageProto.setProfile(try profileProto.build())
return try dataMessageProto.build()
} catch {
SNLog("Couldn't construct profile proto from: \(self).")
return nil
}
}
// MARK: Description
public override var description: String {
"""
Profile(
displayName: \(displayName ?? "null"),
profileKey: \(profileKey?.description ?? "null"),
profilePictureURL: \(profilePictureURL ?? "null")
)
"""
}
}
}

View File

@ -1,9 +1,13 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
public extension VisibleMessage {
@objc(SNQuote)
class Quote : NSObject, NSCoding {
class Quote: NSObject, Codable, NSCoding {
public var timestamp: UInt64?
public var publicKey: String?
public var text: String?
@ -45,14 +49,14 @@ public extension VisibleMessage {
preconditionFailure("Use toProto(using:) instead.")
}
public func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoDataMessageQuote? {
public func toProto(_ db: Database) -> SNProtoDataMessageQuote? {
guard let timestamp = timestamp, let publicKey = publicKey else {
SNLog("Couldn't construct quote proto from: \(self).")
return nil
}
let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: publicKey)
if let text = text { quoteProto.setText(text) }
addAttachmentsIfNeeded(to: quoteProto, using: transaction)
addAttachmentsIfNeeded(db, to: quoteProto)
do {
return try quoteProto.build()
} catch {
@ -61,9 +65,12 @@ public extension VisibleMessage {
}
}
private func addAttachmentsIfNeeded(to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder, using transaction: YapDatabaseReadWriteTransaction) {
private func addAttachmentsIfNeeded(_ db: Database, to quoteProto: SNProtoDataMessageQuote.SNProtoDataMessageQuoteBuilder) {
guard let attachmentID = attachmentID else { return }
guard let stream = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction) as? TSAttachmentStream, stream.isUploaded else {
guard
let attachment: SessionMessagingKit.Attachment = try? SessionMessagingKit.Attachment.fetchOne(db, id: attachmentID),
attachment.state != .uploaded
else {
#if DEBUG
preconditionFailure("Sending a message before all associated attachments have been uploaded.")
#else
@ -71,9 +78,9 @@ public extension VisibleMessage {
#endif
}
let quotedAttachmentProto = SNProtoDataMessageQuoteQuotedAttachment.builder()
quotedAttachmentProto.setContentType(stream.contentType)
if let fileName = stream.sourceFilename { quotedAttachmentProto.setFileName(fileName) }
guard let attachmentProto = stream.buildProto() else {
quotedAttachmentProto.setContentType(attachment.contentType)
if let fileName = attachment.sourceFilename { quotedAttachmentProto.setFileName(fileName) }
guard let attachmentProto = attachment.buildProto() else {
return SNLog("Ignoring invalid attachment for quoted message.")
}
quotedAttachmentProto.setThumbnail(attachmentProto)
@ -97,3 +104,17 @@ public extension VisibleMessage {
}
}
}
// MARK: - Database Type Conversion
public extension VisibleMessage.Quote {
static func from(_ db: Database, quote: Quote) -> VisibleMessage.Quote {
let result = VisibleMessage.Quote()
result.timestamp = UInt64(quote.timestampMs)
result.publicKey = quote.authorId
result.text = quote.body
result.attachmentID = quote.attachmentId
return result
}
}

View File

@ -1,7 +1,21 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import SessionUtilitiesKit
@objc(SNVisibleMessage)
public final class VisibleMessage : Message {
public final class VisibleMessage: Message {
private enum CodingKeys: String, CodingKey {
case syncTarget
case text = "body"
case attachmentIDs = "attachments"
case quote
case linkPreview
case profile
case openGroupInvitation
}
/// In the case of a sync message, the public key of the person the message was targeted at.
///
/// - Note: `nil` if this isn't a sync message.
@ -11,7 +25,7 @@ public final class VisibleMessage : Message {
@objc public var quote: Quote?
@objc public var linkPreview: LinkPreview?
@objc public var contact: Legacy.Contact?
@objc public var profile: Legacy.Profile?
@objc public var profile: Profile?
@objc public var openGroupInvitation: OpenGroupInvitation?
public override var isSelfSendValid: Bool { true }
@ -36,11 +50,10 @@ public final class VisibleMessage : Message {
if let attachmentIDs = coder.decodeObject(forKey: "attachments") as! [String]? { self.attachmentIDs = attachmentIDs }
if let quote = coder.decodeObject(forKey: "quote") as! Quote? { self.quote = quote }
if let linkPreview = coder.decodeObject(forKey: "linkPreview") as! LinkPreview? { self.linkPreview = linkPreview }
// TODO: Contact
if let profile = coder.decodeObject(forKey: "profile") as! Legacy.Profile? { self.profile = profile }
if let profile = coder.decodeObject(forKey: "profile") as! Profile? { self.profile = profile }
if let openGroupInvitation = coder.decodeObject(forKey: "openGroupInvitation") as! OpenGroupInvitation? { self.openGroupInvitation = openGroupInvitation }
}
public override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(syncTarget, forKey: "syncTarget")
@ -48,10 +61,39 @@ public final class VisibleMessage : Message {
coder.encode(attachmentIDs, forKey: "attachments")
coder.encode(quote, forKey: "quote")
coder.encode(linkPreview, forKey: "linkPreview")
// TODO: Contact
coder.encode(profile, forKey: "profile")
coder.encode(openGroupInvitation, forKey: "openGroupInvitation")
}
// MARK: - Codable
required init(from decoder: Decoder) throws {
try super.init(from: decoder)
let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
syncTarget = try? container.decode(String.self, forKey: .syncTarget)
text = try? container.decode(String.self, forKey: .text)
attachmentIDs = ((try? container.decode([String].self, forKey: .attachmentIDs)) ?? [])
quote = try? container.decode(Quote.self, forKey: .quote)
linkPreview = try? container.decode(LinkPreview.self, forKey: .linkPreview)
profile = try? container.decode(Profile.self, forKey: .profile)
openGroupInvitation = try? container.decode(OpenGroupInvitation.self, forKey: .openGroupInvitation)
}
public override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container: KeyedEncodingContainer<CodingKeys> = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(syncTarget, forKey: .syncTarget)
try container.encodeIfPresent(text, forKey: .text)
try container.encodeIfPresent(attachmentIDs, forKey: .attachmentIDs)
try container.encodeIfPresent(quote, forKey: .quote)
try container.encodeIfPresent(linkPreview, forKey: .linkPreview)
try container.encodeIfPresent(profile, forKey: .profile)
try container.encodeIfPresent(openGroupInvitation, forKey: .openGroupInvitation)
}
// MARK: Proto Conversion
public override class func fromProto(_ proto: SNProtoContent, sender: String) -> VisibleMessage? {
@ -62,50 +104,68 @@ public final class VisibleMessage : Message {
if let quoteProto = dataMessage.quote, let quote = Quote.fromProto(quoteProto) { result.quote = quote }
if let linkPreviewProto = dataMessage.preview.first, let linkPreview = LinkPreview.fromProto(linkPreviewProto) { result.linkPreview = linkPreview }
// TODO: Contact
if let profile = Legacy.Profile.fromProto(dataMessage) { result.profile = profile }
if let profile = Profile.fromProto(dataMessage) { result.profile = profile }
if let openGroupInvitationProto = dataMessage.openGroupInvitation,
let openGroupInvitation = OpenGroupInvitation.fromProto(openGroupInvitationProto) { result.openGroupInvitation = openGroupInvitation }
result.syncTarget = dataMessage.syncTarget
return result
}
public override func toProto(using transaction: YapDatabaseReadWriteTransaction) -> SNProtoContent? {
public override func toProto(_ db: Database) -> SNProtoContent? {
let proto = SNProtoContent.builder()
var attachmentIDs = self.attachmentIDs
let dataMessage: SNProtoDataMessage.SNProtoDataMessageBuilder
// Profile
if let profile = profile, let profileProto = profile.toProto() {
dataMessage = profileProto.asBuilder()
} else {
}
else {
dataMessage = SNProtoDataMessage.builder()
}
// Text
if let text = text { dataMessage.setBody(text) }
// Quote
if let quotedAttachmentID = quote?.attachmentID, let index = attachmentIDs.firstIndex(of: quotedAttachmentID) {
attachmentIDs.remove(at: index)
}
if let quote = quote, let quoteProto = quote.toProto(using: transaction) { dataMessage.setQuote(quoteProto) }
if let quote = quote, let quoteProto = quote.toProto(db) {
dataMessage.setQuote(quoteProto)
}
// Link preview
if let linkPreviewAttachmentID = linkPreview?.attachmentID, let index = attachmentIDs.firstIndex(of: linkPreviewAttachmentID) {
attachmentIDs.remove(at: index)
}
if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(using: transaction) { dataMessage.setPreview([ linkPreviewProto ]) }
if let linkPreview = linkPreview, let linkPreviewProto = linkPreview.toProto(db) {
dataMessage.setPreview([ linkPreviewProto ])
}
// Attachments
let attachments = attachmentIDs.compactMap { TSAttachment.fetch(uniqueId: $0, transaction: transaction) as? TSAttachmentStream }
if !attachments.allSatisfy({ $0.isUploaded }) {
let attachments: [SessionMessagingKit.Attachment]? = try? SessionMessagingKit.Attachment.fetchAll(db, ids: self.attachmentIDs)
if !(attachments ?? []).allSatisfy({ $0.state == .uploaded }) {
#if DEBUG
preconditionFailure("Sending a message before all associated attachments have been uploaded.")
#endif
}
let attachmentProtos = attachments.compactMap { $0.buildProto() }
let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() }
dataMessage.setAttachments(attachmentProtos)
// TODO: Contact
// Open group invitation
if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) }
// Group context
do {
try setGroupContextIfNeeded(on: dataMessage, using: transaction)
try setGroupContextIfNeeded(db, on: dataMessage)
} catch {
SNLog("Couldn't construct visible message proto from: \(self).")
return nil
@ -139,3 +199,37 @@ public final class VisibleMessage : Message {
"""
}
}
// MARK: - Database Type Conversion
public extension VisibleMessage {
static func from(_ db: Database, interaction: Interaction) -> VisibleMessage {
let result = VisibleMessage()
result.sentTimestamp = UInt64(interaction.timestampMs)
result.recipient = (try? interaction.recipientStates.fetchOne(db))?.recipientId
if let thread: SessionThread = try? interaction.thread.fetchOne(db), thread.variant == .closedGroup {
result.groupPublicKey = thread.id
}
result.text = interaction.body
result.attachmentIDs = ((try? interaction.attachments.fetchAll(db)) ?? []).map { $0.id }
result.quote = (try? interaction.quote.fetchOne(db))
.map { VisibleMessage.Quote.from(db, quote: $0) }
if let linkPreview: SessionMessagingKit.LinkPreview = try? interaction.linkPreview.fetchOne(db) {
switch linkPreview.variant {
case .standard:
result.linkPreview = VisibleMessage.LinkPreview.from(db, linkPreview: linkPreview)
case .openGroupInvitation:
result.openGroupInvitation = VisibleMessage.OpenGroupInvitation.from(
db,
linkPreview: linkPreview
)
}
}
return result
}
}

View File

@ -5,7 +5,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
#import <SessionMessagingKit/AppReadiness.h>
#import <SessionMessagingKit/Environment.h>
#import <SessionMessagingKit/NotificationsProtocol.h>
#import <SessionMessagingKit/NSData+messagePadding.h>
#import <SessionMessagingKit/OWSAudioPlayer.h>
#import <SessionMessagingKit/OWSBackgroundTask.h>
@ -28,7 +27,6 @@ FOUNDATION_EXPORT const unsigned char SessionMessagingKitVersionString[];
#import <SessionMessagingKit/OWSStorage+Subclass.h>
#import <SessionMessagingKit/OWSUserProfile.h>
#import <SessionMessagingKit/OWSWindowManager.h>
#import <SessionMessagingKit/ProfileManagerProtocol.h>
#import <SessionMessagingKit/ProtoUtils.h>
#import <SessionMessagingKit/SignalRecipient.h>
#import <SessionMessagingKit/SSKEnvironment.h>

View File

@ -44,17 +44,10 @@ private struct OWSThumbnailRequest {
public typealias SuccessBlock = (OWSLoadedThumbnail) -> Void
public typealias FailureBlock = (Error) -> Void
let attachment: TSAttachmentStream
let thumbnailDimensionPoints: UInt
let attachment: Attachment
let dimensions: UInt
let success: SuccessBlock
let failure: FailureBlock
init(attachment: TSAttachmentStream, thumbnailDimensionPoints: UInt, success: @escaping SuccessBlock, failure: @escaping FailureBlock) {
self.attachment = attachment
self.thumbnailDimensionPoints = thumbnailDimensionPoints
self.success = success
self.failure = failure
}
}
@objc public class OWSThumbnailService: NSObject {
@ -75,7 +68,7 @@ private struct OWSThumbnailRequest {
// arrive so that we prioritize the most recent view state.
private var thumbnailRequestStack = [OWSThumbnailRequest]()
private func canThumbnailAttachment(attachment: TSAttachmentStream) -> Bool {
private func canThumbnailAttachment(attachment: Attachment) -> Bool {
return attachment.isImage || attachment.isAnimated || attachment.isVideo
}
@ -88,6 +81,22 @@ private struct OWSThumbnailRequest {
serialQueue.async {
let thumbnailRequest = OWSThumbnailRequest(attachment: attachment, thumbnailDimensionPoints: thumbnailDimensionPoints, success: success, failure: failure)
self.thumbnailRequestStack.append(thumbnailRequest)
public func ensureThumbnail(
for attachment: Attachment,
dimensions: UInt,
success: @escaping SuccessBlock,
failure: @escaping FailureBlock
) {
serialQueue.async {
self.thumbnailRequestStack.append(
OWSThumbnailRequest(
attachment: attachment,
dimensions: dimensions,
success: success,
failure: failure
)
)
self.processNextRequestSync()
}
@ -130,7 +139,7 @@ private struct OWSThumbnailRequest {
guard canThumbnailAttachment(attachment: attachment) else {
throw OWSThumbnailError.failure(description: "Cannot thumbnail attachment.")
}
let thumbnailPath = attachment.path(forThumbnailDimensionPoints: thumbnailRequest.thumbnailDimensionPoints)
let thumbnailPath = attachment.thumbnailPath(for: thumbnailRequest.dimensions)
if FileManager.default.fileExists(atPath: thumbnailPath) {
guard let image = UIImage(contentsOfFile: thumbnailPath) else {
throw OWSThumbnailError.failure(description: "Could not load thumbnail.")
@ -145,7 +154,7 @@ private struct OWSThumbnailRequest {
guard let originalFilePath = attachment.originalFilePath else {
throw OWSThumbnailError.failure(description: "Missing original file path.")
}
let maxDimension = CGFloat(thumbnailRequest.thumbnailDimensionPoints)
let maxDimension = CGFloat(thumbnailRequest.dimensions)
let thumbnailImage: UIImage
if attachment.isImage || attachment.isAnimated {
thumbnailImage = try OWSMediaUtils.thumbnail(forImageAtPath: originalFilePath, maxDimension: maxDimension)

View File

@ -0,0 +1,51 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
public enum MessageReceiverError: LocalizedError {
case duplicateMessage
case invalidMessage
case unknownMessage
case unknownEnvelopeType
case noUserX25519KeyPair
case noUserED25519KeyPair
case invalidSignature
case noData
case senderBlocked
case noThread
case selfSend
case decryptionFailed
case invalidGroupPublicKey
case noGroupKeyPair
public var isRetryable: Bool {
switch self {
case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType,
.invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed:
return false
default: return true
}
}
public var errorDescription: String? {
switch self {
case .duplicateMessage: return "Duplicate message."
case .invalidMessage: return "Invalid message."
case .unknownMessage: return "Unknown message type."
case .unknownEnvelopeType: return "Unknown envelope type."
case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair."
case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair."
case .invalidSignature: return "Invalid message signature."
case .noData: return "Received an empty envelope."
case .senderBlocked: return "Received a message from a blocked user."
case .noThread: return "Couldn't find thread for message."
case .selfSend: return "Message addressed at self."
case .decryptionFailed: return "Decryption failed."
// Shared sender keys
case .invalidGroupPublicKey: return "Invalid group public key."
case .noGroupKeyPair: return "Missing group key pair."
}
}
}

View File

@ -0,0 +1,45 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
public enum MessageSenderError: LocalizedError {
case invalidMessage
case protoConversionFailed
case noUserX25519KeyPair
case noUserED25519KeyPair
case signingFailed
case encryptionFailed
case noUsername
// Closed groups
case noThread
case noKeyPair
case invalidClosedGroupUpdate
case other(Error)
internal var isRetryable: Bool {
switch self {
case .invalidMessage, .protoConversionFailed, .invalidClosedGroupUpdate, .signingFailed, .encryptionFailed: return false
default: return true
}
}
public var errorDescription: String? {
switch self {
case .invalidMessage: return "Invalid message."
case .protoConversionFailed: return "Couldn't convert message to proto."
case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair."
case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair."
case .signingFailed: return "Couldn't sign message."
case .encryptionFailed: return "Couldn't encrypt message."
case .noUsername: return "Missing username."
// Closed groups
case .noThread: return "Couldn't find a thread associated with the given group public key."
case .noKeyPair: return "Couldn't find a private key associated with the given group public key."
case .invalidClosedGroupUpdate: return "Invalid group update."
case .other(let error): return error.localizedDescription
}
}
}

View File

@ -33,7 +33,7 @@ public final class MentionsManager : NSObject {
let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID)
storage.dbReadConnection.read { transaction in
candidates = cache.compactMap { publicKey in
guard let displayName: String = Profile.displayNameNoFallback(for: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else {
guard let displayName: String = Profile.displayNameNoFallback(id: publicKey, context: (openGroupV2 != nil ? .openGroup : .regular)) else {
return nil
}
guard !displayName.hasPrefix("Anonymous") else { return nil }

View File

@ -1,12 +1,46 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoSwift
import GRDB
import Sodium
import CryptoSwift
import Curve25519Kit
import SessionUtilitiesKit
extension MessageReceiver {
internal static func extractSenderPublicKey(_ db: Database, from envelope: SNProtoEnvelope) -> String? {
guard
let ciphertext: Data = envelope.content,
let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db)
else { return nil }
let recipientX25519PrivateKey = userX25519KeyPair.secretKey
let recipientX25519PublicKey = userX25519KeyPair.publicKey
let sodium = Sodium()
let signatureSize = sodium.sign.Bytes
let ed25519PublicKeySize = sodium.sign.PublicKeyBytes
// 1. ) Decrypt the message
guard
let plaintextWithMetadata = sodium.box.open(
anonymousCipherText: Bytes(ciphertext),
recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)),
recipientSecretKey: Bytes(recipientX25519PrivateKey)
),
plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize)
else { return nil }
// 2. ) Get the message parts
let senderED25519PublicKey = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - (signatureSize + ed25519PublicKeySize) ..< plaintextWithMetadata.count - signatureSize])
// 3. ) Get the sender's X25519 public key
guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else {
return nil
}
return "05\(senderX25519PublicKey.toHexString())"
}
internal static func decryptWithSessionProtocol(ciphertext: Data, using x25519KeyPair: Box.KeyPair) throws -> (plaintext: Data, senderX25519PublicKey: String) {
let recipientX25519PrivateKey = x25519KeyPair.secretKey
@ -17,7 +51,7 @@ extension MessageReceiver {
// 1. ) Decrypt the message
guard let plaintextWithMetadata = sodium.box.open(anonymousCipherText: Bytes(ciphertext), recipientPublicKey: Box.PublicKey(Bytes(recipientX25519PublicKey)),
recipientSecretKey: Bytes(recipientX25519PrivateKey)), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { throw Error.decryptionFailed }
recipientSecretKey: Bytes(recipientX25519PrivateKey)), plaintextWithMetadata.count > (signatureSize + ed25519PublicKeySize) else { throw MessageReceiverError.decryptionFailed }
// 2. ) Get the message parts
let signature = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - signatureSize ..< plaintextWithMetadata.count])
let senderED25519PublicKey = Bytes(plaintextWithMetadata[plaintextWithMetadata.count - (signatureSize + ed25519PublicKeySize) ..< plaintextWithMetadata.count - signatureSize])
@ -25,9 +59,9 @@ extension MessageReceiver {
// 3. ) Verify the signature
let verificationData = plaintext + senderED25519PublicKey + recipientX25519PublicKey
let isValid = sodium.sign.verify(message: verificationData, publicKey: senderED25519PublicKey, signature: signature)
guard isValid else { throw Error.invalidSignature }
guard isValid else { throw MessageReceiverError.invalidSignature }
// 4. ) Get the sender's X25519 public key
guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw Error.decryptionFailed }
guard let senderX25519PublicKey = sodium.sign.toX25519(ed25519PublicKey: senderED25519PublicKey) else { throw MessageReceiverError.decryptionFailed }
return (Data(plaintext), "05" + senderX25519PublicKey.toHexString())
}

View File

@ -8,124 +8,114 @@ import SessionUtilitiesKit
public enum MessageReceiver {
private static var lastEncryptionKeyPairRequest: [String: Date] = [:]
public enum Error : LocalizedError {
case duplicateMessage
case invalidMessage
case unknownMessage
case unknownEnvelopeType
case noUserX25519KeyPair
case noUserED25519KeyPair
case invalidSignature
case noData
case senderBlocked
case noThread
case selfSend
case decryptionFailed
case invalidGroupPublicKey
case noGroupKeyPair
public var isRetryable: Bool {
switch self {
case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType,
.invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed: return false
default: return true
}
}
public var errorDescription: String? {
switch self {
case .duplicateMessage: return "Duplicate message."
case .invalidMessage: return "Invalid message."
case .unknownMessage: return "Unknown message type."
case .unknownEnvelopeType: return "Unknown envelope type."
case .noUserX25519KeyPair: return "Couldn't find user X25519 key pair."
case .noUserED25519KeyPair: return "Couldn't find user ED25519 key pair."
case .invalidSignature: return "Invalid message signature."
case .noData: return "Received an empty envelope."
case .senderBlocked: return "Received a message from a blocked user."
case .noThread: return "Couldn't find thread for message."
case .selfSend: return "Message addressed at self."
case .decryptionFailed: return "Decryption failed."
// Shared sender keys
case .invalidGroupPublicKey: return "Invalid group public key."
case .noGroupKeyPair: return "Missing group key pair."
}
}
}
public static func parse(_ db: Database, _ data: Data, openGroupMessageServerID: UInt64?, isRetry: Bool = false, using transaction: Any) throws -> (Message, SNProtoContent) {
let userPublicKey = getUserHexEncodedPublicKey()
let isOpenGroupMessage = (openGroupMessageServerID != nil)
public static func parse(
_ db: Database,
data: Data,
openGroupId: String? = nil,
openGroupMessageServerId: UInt64? = nil,
isRetry: Bool = false
) throws -> (Message, SNProtoContent) {
let userPublicKey: String = getUserHexEncodedPublicKey()
let isOpenGroupMessage: Bool = (openGroupMessageServerId != nil)
// Parse the envelope
let envelope = try SNProtoEnvelope.parseData(data)
let storage = SNMessagingKitConfiguration.shared.storage
// Decrypt the contents
guard let ciphertext = envelope.content else { throw Error.noData }
var plaintext: Data!
var sender: String!
guard let ciphertext = envelope.content else { throw MessageReceiverError.noData }
var plaintext: Data
var sender: String
var groupPublicKey: String? = nil
if isOpenGroupMessage {
(plaintext, sender) = (envelope.content!, envelope.source!)
} else {
}
else {
switch envelope.type {
case .sessionMessage:
guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { throw Error.noUserX25519KeyPair }
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair)
case .closedGroupMessage:
guard let hexEncodedGroupPublicKey = envelope.source, SNMessagingKitConfiguration.shared.storage.isClosedGroup(hexEncodedGroupPublicKey) else { throw Error.invalidGroupPublicKey }
var encryptionKeyPairs = Storage.shared.getClosedGroupEncryptionKeyPairs(for: hexEncodedGroupPublicKey)
guard !encryptionKeyPairs.isEmpty else { throw Error.noGroupKeyPair }
// Loop through all known group key pairs in reverse order (i.e. try the latest key pair first (which'll more than
// likely be the one we want) but try older ones in case that didn't work)
var encryptionKeyPair = encryptionKeyPairs.removeLast()
func decrypt() throws {
case .sessionMessage:
guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else {
throw MessageReceiverError.noUserX25519KeyPair
}
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: userX25519KeyPair)
case .closedGroupMessage:
guard
let hexEncodedGroupPublicKey = envelope.source,
let closedGroup: ClosedGroup = try? ClosedGroup.fetchOne(db, id: hexEncodedGroupPublicKey)
else {
throw MessageReceiverError.invalidGroupPublicKey
}
guard
let encryptionKeyPairs: [ClosedGroupKeyPair] = try? closedGroup.keyPairs.order(ClosedGroupKeyPair.Columns.receivedTimestamp.desc).fetchAll(db),
!encryptionKeyPairs.isEmpty
else {
throw MessageReceiverError.noGroupKeyPair
}
// Loop through all known group key pairs in reverse order (i.e. try the latest key
// pair first (which'll more than likely be the one we want) but try older ones in
// case that didn't work)
func decrypt(keyPairs: [ClosedGroupKeyPair], lastError: Error? = nil) throws -> (Data, String) {
guard let keyPair: ClosedGroupKeyPair = keyPairs.first else {
throw (lastError ?? MessageReceiverError.decryptionFailed)
}
do {
return try decryptWithSessionProtocol(
ciphertext: ciphertext,
using: Box.KeyPair(
publicKey: Data(hex: keyPair.publicKey).bytes,
secretKey: keyPair.secretKey.bytes
)
)
}
catch {
return try decrypt(keyPairs: Array(keyPairs.suffix(from: 1)), lastError: error)
}
}
groupPublicKey = hexEncodedGroupPublicKey
(plaintext, sender) = try decrypt(keyPairs: encryptionKeyPairs)
/*
do {
(plaintext, sender) = try decryptWithSessionProtocol(ciphertext: ciphertext, using: encryptionKeyPair)
try decrypt()
} catch {
if !encryptionKeyPairs.isEmpty {
encryptionKeyPair = encryptionKeyPairs.removeLast()
try decrypt()
} else {
throw error
do {
let now = Date()
// Don't spam encryption key pair requests
let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true
if shouldRequestEncryptionKeyPair {
try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction)
lastEncryptionKeyPairRequest[groupPublicKey!] = now
}
}
throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one)
}
}
groupPublicKey = envelope.source
try decrypt()
/*
do {
try decrypt()
} catch {
do {
let now = Date()
// Don't spam encryption key pair requests
let shouldRequestEncryptionKeyPair = given(lastEncryptionKeyPairRequest[groupPublicKey!]) { now.timeIntervalSince($0) > 30 } ?? true
if shouldRequestEncryptionKeyPair {
try MessageSender.requestEncryptionKeyPair(for: groupPublicKey!, using: transaction as! YapDatabaseReadWriteTransaction)
lastEncryptionKeyPairRequest[groupPublicKey!] = now
}
}
throw error // Throw the * decryption * error and not the error generated by requestEncryptionKeyPair (if it generated one)
}
*/
default: throw Error.unknownEnvelopeType
*/
default: throw MessageReceiverError.unknownEnvelopeType
}
}
// Don't process the envelope any further if the sender is blocked
guard GRDBStorage.shared.read({ db in try Contact.fetchOne(db, id: sender) })?.isBlocked != true else {
// guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true else {
throw Error.senderBlocked
guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true else {
throw MessageReceiverError.senderBlocked
}
// Parse the proto
let proto: SNProtoContent
do {
proto = try SNProtoContent.parseData((plaintext as NSData).removePadding())
} catch {
}
catch {
SNLog("Couldn't parse proto due to error: \(error).")
throw error
}
// Parse the message
let message: Message? = {
if let readReceipt = ReadReceipt.fromProto(proto, sender: sender) { return readReceipt }
@ -139,50 +129,82 @@ public enum MessageReceiver {
if let visibleMessage = VisibleMessage.fromProto(proto, sender: sender) { return visibleMessage }
return nil
}()
if let message = message {
// Ignore self sends if needed
if !message.isSelfSendValid {
guard sender != userPublicKey else { throw Error.selfSend }
guard sender != userPublicKey else { throw MessageReceiverError.selfSend }
}
// Guard against control messages in open groups
if isOpenGroupMessage {
guard message is VisibleMessage else { throw Error.invalidMessage }
guard message is VisibleMessage else { throw MessageReceiverError.invalidMessage }
}
// Finish parsing
message.sender = sender
message.recipient = userPublicKey
message.sentTimestamp = envelope.timestamp
message.receivedTimestamp = NSDate.millisecondTimestamp()
if isOpenGroupMessage {
message.openGroupServerTimestamp = envelope.serverTimestamp
}
message.receivedTimestamp = UInt64((Date().timeIntervalSince1970) * 1000)
message.groupPublicKey = groupPublicKey
message.openGroupServerMessageID = openGroupMessageServerID
message.openGroupServerMessageID = openGroupMessageServerId
// Validate
var isValid = message.isValid
var isValid: Bool = message.isValid
if message is VisibleMessage && !isValid && proto.dataMessage?.attachments.isEmpty == false {
isValid = true
}
guard isValid else {
throw Error.invalidMessage
throw MessageReceiverError.invalidMessage
}
// If the message failed to process the first time around we retry it later (if the error is retryable). In this case the timestamp
// will already be in the database but we don't want to treat the message as a duplicate. The isRetry flag is a simple workaround
// for this issue.
if let message = message as? ClosedGroupControlMessage, case .new = message.kind {
switch (isRetry, message, (message as? ClosedGroupControlMessage)?.kind) {
// Allow duplicates in this case to avoid the following situation:
// The app performed a background poll or received a push notification
// This method was invoked and the received message timestamps table was updated
// Processing wasn't finished
// The user doesn't see the new closed group
} else {
guard !Set(storage.getReceivedMessageTimestamps(using: transaction)).contains(envelope.timestamp) || isRetry else { throw Error.duplicateMessage }
storage.addReceivedMessageTimestamp(envelope.timestamp, using: transaction)
// The user doesn't see theO new closed group
case (_, _, .new): break
// All `VisibleMessage` values will have an associated `Interaction` so just let
// the unique constraints on that table prevent duplicate messages
case is (Bool, VisibleMessage, ClosedGroupControlMessage.Kind?): break
// If the message failed to process and we are retrying then there will already
// be a `ControlMessageProcessRecord`, so just allow this through
case (true, _, _): break
default:
do {
try ControlMessageProcessRecord(
threadId: {
if let openGroupId: String = openGroupId {
return openGroupId
}
if let groupPublicKey: String = groupPublicKey {
return groupPublicKey
}
return sender
}(),
sentTimestampMs: Int64(envelope.timestamp),
serverHash: (message.serverHash ?? ""),
openGroupMessageServerId: (openGroupMessageServerId.map { Int64($0) } ?? 0)
).insert(db)
}
catch { throw MessageReceiverError.duplicateMessage }
}
// Return
return (message, proto)
} else {
throw Error.unknownMessage
}
throw MessageReceiverError.unknownMessage
}
}

View File

@ -1,84 +1,137 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import Sodium
import Curve25519Kit
import PromiseKit
import SessionUtilitiesKit
extension MessageSender {
public static var distributingClosedGroupEncryptionKeyPairs: [String: [Box.KeyPair]] = [:]
public static func createClosedGroup(name: String, members: Set<String>, transaction: YapDatabaseReadWriteTransaction) -> Promise<TSGroupThread> {
// Prepare
var members = members
let userPublicKey = getUserHexEncodedPublicKey()
public static func createClosedGroup(_ db: Database, name: String, members: Set<String>) throws -> Promise<SessionThread> {
let userPublicKey: String = getUserHexEncodedPublicKey()
var members: Set<String> = members
// Generate the group's public key
let groupPublicKey = Curve25519.generateKeyPair().hexEncodedPublicKey // Includes the "05" prefix
// Generate the key pair that'll be used for encryption and decryption
let encryptionKeyPair = Curve25519.generateKeyPair()
// Ensure the current user is included in the member list
members.insert(userPublicKey)
let membersAsData = members.map { Data(hex: $0) }
// Create the group
let admins = [ userPublicKey ]
let adminsAsData = admins.map { Data(hex: $0) }
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let group = TSGroupModel(title: name, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: admins)
let thread = TSGroupThread.getOrCreateThread(with: group, transaction: transaction)
thread.save(with: transaction)
// Send a closed group update message to all members individually
var promises: [Promise<Void>] = []
for member in members {
let thread = TSContactThread.getOrCreateThread(withContactSessionID: member, transaction: transaction)
thread.save(with: transaction)
let keyPair: Box.KeyPair = Box.KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
)
let closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: name,
encryptionKeyPair: keyPair, members: membersAsData, admins: adminsAsData, expirationTimer: 0)
let closedGroupControlMessage = ClosedGroupControlMessage(kind: closedGroupControlMessageKind)
// Sending this non-durably is okay because we show a loader to the user. If they close the app while the
// loader is still showing, it's within expectation that the group creation might be incomplete.
let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction)
promises.append(promise)
}
// Add the group to the user's set of public keys to poll for
Storage.shared.addClosedGroupPublicKey(groupPublicKey, using: transaction)
// Store the key pair
let keyPair: Box.KeyPair = Box.KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.privateKey.bytes
)
Storage.shared.addClosedGroupEncryptionKeyPair(keyPair, for: groupPublicKey, using: transaction)
// Create the group
members.insert(userPublicKey) // Ensure the current user is included in the member list
let membersAsData = members.map { Data(hex: $0) }
let admins = [ userPublicKey ]
let adminsAsData = admins.map { Data(hex: $0) }
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: groupPublicKey, variant: .closedGroup)
.saved(db)
try ClosedGroup(
threadId: groupPublicKey,
name: name,
formationTimestamp: Date().timeIntervalSince1970
).insert(db)
try admins.forEach { adminId in
try GroupMember(
groupId: groupPublicKey,
profileId: adminId,
role: .admin
).insert(db)
}
// Send a closed group update message to all members individually
var promises: [Promise<Void>] = []
try members.forEach { adminId in
try GroupMember(
groupId: groupPublicKey,
profileId: adminId,
role: .admin
).insert(db)
}
try members.forEach { memberId in
let contactThread: SessionThread = try SessionThread
.fetchOrCreate(db, id: memberId, variant: .contact)
.saved(db)
// Sending this non-durably is okay because we show a loader to the user. If they
// close the app while the loader is still showing, it's within expectation that
// the group creation might be incomplete.
promises.append(
try MessageSender.sendNonDurably(
db,
message: ClosedGroupControlMessage(
kind: .new(
publicKey: Data(hex: groupPublicKey),
name: name,
encryptionKeyPair: keyPair,
members: membersAsData,
admins: adminsAsData,
expirationTimer: 0
)
),
interactionId: nil,
in: contactThread
)
)
}
// Store the key pair
try ClosedGroupKeyPair(
publicKey: keyPair.publicKey.toHexString(),
secretKey: Data(keyPair.secretKey),
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
// Notify the PN server
promises.append(PushNotificationAPI.performOperation(.subscribe, for: groupPublicKey, publicKey: userPublicKey))
promises.append(
PushNotificationAPI.performOperation(
.subscribe,
for: groupPublicKey,
publicKey: userPublicKey
)
)
// Notify the user
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupCreated)
infoMessage.save(with: transaction)
//
// Note: Intentionally don't want a 'serverHash' for closed group creation
_ = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupCreated,
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
// Start polling
ClosedGroupPoller.shared.startPolling(for: groupPublicKey)
// Return
return when(fulfilled: promises).map2 { thread }
}
/// Generates and distributes a new encryption key pair for the group with the given `groupPublicKey`. This sends a `ENCRYPTION_KEY_PAIR` message to the group. The
/// message contains a list of key pair wrappers. Each key pair wrapper consists of the public key for which the wrapper is intended along with the newly generated key pair
/// Generates and distributes a new encryption key pair for the group with the given closed group. This sends an
/// `ENCRYPTION_KEY_PAIR` message to the group. The message contains a list of key pair wrappers. Each key
/// pair wrapper consists of the public key for which the wrapper is intended along with the newly generated key pair
/// encrypted for that public key.
///
/// The returned promise is fulfilled when the message has been sent to the group.
public static func generateAndSendNewEncryptionKeyPair(for groupPublicKey: String, to targetMembers: Set<String>, using transaction: Any) -> Promise<Void> {
// Prepare
let transaction = transaction as! YapDatabaseReadWriteTransaction
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
SNLog("Can't distribute new encryption key pair for nonexistent closed group.")
return Promise(error: Error.noThread)
}
guard thread.groupModel.groupAdminIds.contains(getUserHexEncodedPublicKey()) else {
SNLog("Can't distribute new encryption key pair as a non-admin.")
return Promise(error: Error.invalidClosedGroupUpdate)
private static func generateAndSendNewEncryptionKeyPair(
_ db: Database,
targetMembers: Set<String>,
userPublicKey: String,
allGroupMembers: [GroupMember],
closedGroup: ClosedGroup,
thread: SessionThread
) throws -> Promise<Void> {
guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
// Generate the new encryption key pair
let newLegacyKeyPair = Curve25519.generateKeyPair()
@ -86,223 +139,435 @@ extension MessageSender {
publicKey: newLegacyKeyPair.publicKey.bytes,
secretKey: newLegacyKeyPair.privateKey.bytes
)
// Distribute it
let proto = try! SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey),
let proto = try SNProtoKeyPair.builder(publicKey: Data(newKeyPair.publicKey),
privateKey: Data(newKeyPair.secretKey)).build()
let plaintext = try! proto.serializedData()
let wrappers = targetMembers.compactMap { publicKey -> ClosedGroupControlMessage.KeyPairWrapper in
let ciphertext = try! MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey)
return ClosedGroupControlMessage.KeyPairWrapper(publicKey: publicKey, encryptedKeyPair: ciphertext)
}
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPair(publicKey: nil, wrappers: wrappers))
var distributingKeyPairs = distributingClosedGroupEncryptionKeyPairs[groupPublicKey] ?? []
let plaintext = try proto.serializedData()
var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? [])
distributingKeyPairs.append(newKeyPair)
distributingClosedGroupEncryptionKeyPairs[groupPublicKey] = distributingKeyPairs
return MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).done {
// Store it * after * having sent out the message to the group
SNMessagingKitConfiguration.shared.storage.write { transaction in
Storage.shared.addClosedGroupEncryptionKeyPair(newKeyPair, for: groupPublicKey, using: transaction)
}
var distributingKeyPairs = distributingClosedGroupEncryptionKeyPairs[groupPublicKey] ?? []
if let index = distributingKeyPairs.firstIndex(of: newKeyPair) {
distributingKeyPairs.remove(at: index)
}
distributingClosedGroupEncryptionKeyPairs[groupPublicKey] = distributingKeyPairs
}.map { _ in }
distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs
do {
return try MessageSender
.sendNonDurably(
db,
message: ClosedGroupControlMessage(
kind: .encryptionKeyPair(
publicKey: nil,
wrappers: targetMembers.map { memberPublicKey in
ClosedGroupControlMessage.KeyPairWrapper(
publicKey: memberPublicKey,
encryptedKeyPair: try MessageSender.encryptWithSessionProtocol(
plaintext,
for: memberPublicKey
)
)
}
)
),
interactionId: nil,
in: thread
)
.done {
/// Store it **after** having sent out the message to the group
GRDBStorage.shared.write { db in
try ClosedGroupKeyPair(
publicKey: newKeyPair.publicKey.toHexString(),
secretKey: Data(newKeyPair.secretKey),
receivedTimestamp: Date().timeIntervalSince1970
).insert(db)
var distributingKeyPairs = (distributingClosedGroupEncryptionKeyPairs[closedGroup.id] ?? [])
if let index = distributingKeyPairs.firstIndex(of: newKeyPair) {
distributingKeyPairs.remove(at: index)
distributingClosedGroupEncryptionKeyPairs[closedGroup.id] = distributingKeyPairs
}
}
}
.map { _ in }
}
catch {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
}
public static func update(_ groupPublicKey: String, with members: Set<String>, name: String, transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
public static func update(
_ db: Database,
groupPublicKey: String,
with members: Set<String>,
name: String
) throws -> Promise<Void> {
// Get the group, check preconditions & prepare
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
SNLog("Can't update nonexistent closed group.")
return Promise(error: Error.noThread)
return Promise(error: MessageSenderError.noThread)
}
let group = thread.groupModel
var promises: [Promise<Void>] = []
let zombies = SNMessagingKitConfiguration.shared.storage.getZombieMembers(for: groupPublicKey)
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
let userPublicKey: String = getUserHexEncodedPublicKey(db)
// Update name if needed
if name != group.groupName { promises.append(setName(to: name, for: groupPublicKey, using: transaction)) }
if name != closedGroup.name {
// Update the group
let updatedClosedGroup: ClosedGroup = closedGroup.with(name: name)
try updatedClosedGroup.save(db)
// Notify the user
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.nameChange(name: name)
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
guard let interactionId: Int64 = interaction.id else {
throw GRDBStorageError.objectNotSaved
}
// Send the update to the group
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name))
try MessageSender.send(
db,
message: closedGroupControlMessage,
interactionId: interactionId,
in: thread
)
}
// Retrieve member info
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
let standardAndZombieMemberIds: [String] = allGroupMembers
.filter { $0.role == .standard || $0.role == .zombie }
.map { $0.profileId }
let addedMembers: Set<String> = members.subtracting(standardAndZombieMemberIds)
// Add members if needed
let addedMembers = members.subtracting(group.groupMemberIds + zombies)
if !addedMembers.isEmpty { promises.append(addMembers(addedMembers, to: groupPublicKey, using: transaction)) }
if !addedMembers.isEmpty {
do {
try addMembers(
db,
addedMembers: addedMembers,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup,
thread: thread
)
}
catch {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
}
// Remove members if needed
let removedMembers = Set(group.groupMemberIds + zombies).subtracting(members)
if !removedMembers.isEmpty{ promises.append(removeMembers(removedMembers, to: groupPublicKey, using: transaction)) }
// Return
return when(fulfilled: promises).map2 { _ in }
}
/// Sets the name to `name` for the group with the given `groupPublicKey`. This sends a `NAME_CHANGE` message to the group.
public static func setName(to name: String, for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
// Get the group, check preconditions & prepare
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
SNLog("Can't change name for nonexistent closed group.")
return Promise(error: Error.noThread)
let removedMembers: Set<String> = Set(standardAndZombieMemberIds).subtracting(members)
if !removedMembers.isEmpty {
do {
return try removeMembers(
db,
removedMembers: removedMembers,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup,
thread: thread
)
}
catch {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
}
guard !name.isEmpty else {
SNLog("Can't set closed group name to an empty value.")
return Promise(error: Error.invalidClosedGroupUpdate)
}
let group = thread.groupModel
// Send the update to the group
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .nameChange(name: name))
MessageSender.send(closedGroupControlMessage, in: thread, using: transaction)
// Update the group
let newGroupModel = TSGroupModel(title: name, memberIds: group.groupMemberIds, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds)
thread.setGroupModel(newGroupModel, with: transaction)
// Notify the user
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo)
infoMessage.save(with: transaction)
// Return
return Promise.value(())
}
/// Adds `newMembers` to the group with the given `groupPublicKey`. This sends a `MEMBERS_ADDED` message to the group, and a
/// Adds `newMembers` to the group with the given closed group. This sends a `MEMBERS_ADDED` message to the group, and a
/// `NEW` message to the members that were added (using one-on-one channels).
public static func addMembers(_ newMembers: Set<String>, to groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
// Get the group, check preconditions & prepare
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
SNLog("Can't add members to nonexistent closed group.")
return Promise(error: Error.noThread)
private static func addMembers(
_ db: Database,
addedMembers: Set<String>,
userPublicKey: String,
allGroupMembers: [GroupMember],
closedGroup: ClosedGroup,
thread: SessionThread
) throws {
guard let disappearingMessagesConfig: DisappearingMessagesConfiguration = try thread.disappearingMessagesConfiguration.fetchOne(db) else {
throw GRDBStorageError.objectNotFound
}
guard !newMembers.isEmpty else {
SNLog("Invalid closed group update.")
return Promise(error: Error.invalidClosedGroupUpdate)
guard let encryptionKeyPair: ClosedGroupKeyPair = try closedGroup.fetchLatestKeyPair(db) else {
throw GRDBStorageError.objectNotFound
}
let group = thread.groupModel
let members = [String](Set(group.groupMemberIds).union(newMembers))
let membersAsData = members.map { Data(hex: $0) }
let adminsAsData = group.groupAdminIds.map { Data(hex: $0) }
let expirationTimer = thread.disappearingMessagesDuration(with: transaction)
guard let encryptionKeyPair = Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else {
SNLog("Couldn't find encryption key pair for closed group: \(groupPublicKey).")
return Promise(error: Error.noKeyPair)
}
// Send the update to the group
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .membersAdded(members: newMembers.map { Data(hex: $0) }))
MessageSender.send(closedGroupControlMessage, in: thread, using: transaction)
// Send updates to the new members individually
for member in newMembers {
let thread = TSContactThread.getOrCreateThread(withContactSessionID: member, transaction: transaction)
thread.save(with: transaction)
let closedGroupControlMessageKind = ClosedGroupControlMessage.Kind.new(publicKey: Data(hex: groupPublicKey), name: group.groupName!,
encryptionKeyPair: encryptionKeyPair, members: membersAsData, admins: adminsAsData, expirationTimer: expirationTimer)
let closedGroupControlMessage = ClosedGroupControlMessage(kind: closedGroupControlMessageKind)
MessageSender.send(closedGroupControlMessage, in: thread, using: transaction)
}
// Update the group
let newGroupModel = TSGroupModel(title: group.groupName, memberIds: members, image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds)
thread.setGroupModel(newGroupModel, with: transaction)
let groupMemberIds: [String] = allGroupMembers
.filter { $0.role == .standard }
.map { $0.profileId }
let groupAdminIds: [String] = allGroupMembers
.filter { $0.role == .admin }
.map { $0.profileId }
let members: Set<String> = Set(groupMemberIds).union(addedMembers)
let membersAsData: [Data] = members.map { Data(hex: $0) }
let adminsAsData: [Data] = groupAdminIds.map { Data(hex: $0) }
// Notify the user
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo)
infoMessage.save(with: transaction)
// Return
return Promise.value(())
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.membersAdded(members: addedMembers.map { Data(hex: $0) })
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
guard let interactionId: Int64 = interaction.id else {
throw GRDBStorageError.objectNotSaved
}
// Send the update to the group
try MessageSender.send(
db,
message: ClosedGroupControlMessage(
kind: .membersAdded(members: addedMembers.map { Data(hex: $0) })
),
interactionId: interactionId,
in: thread
)
try addedMembers.forEach { member in
// Send updates to the new members individually
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: member, variant: .contact)
.saved(db)
try MessageSender.send(
db,
message: ClosedGroupControlMessage(
kind: .new(
publicKey: Data(hex: closedGroup.id),
name: closedGroup.name,
encryptionKeyPair: Box.KeyPair(
publicKey: encryptionKeyPair.publicKey.bytes,
secretKey: encryptionKeyPair.secretKey.bytes
),
members: membersAsData,
admins: adminsAsData,
expirationTimer: (disappearingMessagesConfig.isEnabled ?
UInt32(floor(disappearingMessagesConfig.durationSeconds)) :
0
)
)
),
interactionId: nil,
in: thread
)
// Add the users to the group
try GroupMember(
groupId: closedGroup.id,
profileId: member,
role: .standard
).insert(db)
}
}
/// Removes `membersToRemove` from the group with the given `groupPublicKey`. Only the admin can remove members, and when they do
/// they generate and distribute a new encryption key pair for the group. A member cannot leave a group using this method. For that they should use
/// `leave(:using:)`.
///
/// The returned promise is fulfilled when the `MEMBERS_REMOVED` message has been sent to the group AND the new encryption key pair has been
/// generated and distributed.
public static func removeMembers(_ membersToRemove: Set<String>, to groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
// Get the group, check preconditions & prepare
let userPublicKey = getUserHexEncodedPublicKey()
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
let storage = SNMessagingKitConfiguration.shared.storage
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
SNLog("Can't remove members from nonexistent closed group.")
return Promise(error: Error.noThread)
}
guard !membersToRemove.isEmpty else {
private static func removeMembers(
_ db: Database,
removedMembers: Set<String>,
userPublicKey: String,
allGroupMembers: [GroupMember],
closedGroup: ClosedGroup,
thread: SessionThread
) throws -> Promise<Void> {
guard !removedMembers.contains(userPublicKey) else {
SNLog("Invalid closed group update.")
return Promise(error: Error.invalidClosedGroupUpdate)
throw MessageSenderError.invalidClosedGroupUpdate
}
guard !membersToRemove.contains(userPublicKey) else {
SNLog("Invalid closed group update.")
return Promise(error: Error.invalidClosedGroupUpdate)
}
let group = thread.groupModel
guard group.groupAdminIds.contains(userPublicKey) else {
guard allGroupMembers.contains(where: { $0.role == .admin && $0.profileId == userPublicKey }) else {
SNLog("Only an admin can remove members from a group.")
return Promise(error: Error.invalidClosedGroupUpdate)
throw MessageSenderError.invalidClosedGroupUpdate
}
let members = Set(group.groupMemberIds).subtracting(membersToRemove)
// Update zombie list
let oldZombies = storage.getZombieMembers(for: groupPublicKey)
let newZombies = oldZombies.subtracting(membersToRemove)
storage.setZombieMembers(for: groupPublicKey, to: newZombies, using: transaction)
// Send the update to the group and generate + distribute a new encryption key pair
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .membersRemoved(members: membersToRemove.map { Data(hex: $0) }))
let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).map {
generateAndSendNewEncryptionKeyPair(for: groupPublicKey, to: members, using: transaction)
}.map { _ in }
// Update the group
let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: group.groupAdminIds)
thread.setGroupModel(newGroupModel, with: transaction)
let groupMemberIds: [String] = allGroupMembers
.filter { $0.role == .standard }
.map { $0.profileId }
let groupZombieIds: [String] = allGroupMembers
.filter { $0.role == .zombie }
.map { $0.profileId }
let members: Set<String> = Set(groupMemberIds).subtracting(removedMembers)
// Update zombie * member list
try allGroupMembers
.filter { member in
removedMembers.contains(member.profileId) && (
member.role == .standard ||
member.role == .zombie
)
}
.forEach { try $0.delete(db) }
let interactionId: Int64?
// Notify the user if needed (not if only zombie members were removed)
if !membersToRemove.subtracting(oldZombies).isEmpty {
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupUpdated, customMessage: updateInfo)
infoMessage.save(with: transaction)
if !removedMembers.subtracting(groupZombieIds).isEmpty {
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupUpdated,
body: ClosedGroupControlMessage.Kind
.membersRemoved(members: removedMembers.map { Data(hex: $0) })
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
guard let newInteractionId: Int64 = interaction.id else {
throw GRDBStorageError.objectNotSaved
}
interactionId = newInteractionId
}
// Return
else {
interactionId = nil
}
// Send the update to the group and generate + distribute a new encryption key pair
let promise = try MessageSender
.sendNonDurably(
db,
message: ClosedGroupControlMessage(
kind: .membersRemoved(
members: removedMembers.map { Data(hex: $0) }
)
),
interactionId: interactionId,
in: thread
)
.map { _ in
try generateAndSendNewEncryptionKeyPair(
db,
targetMembers: members,
userPublicKey: userPublicKey,
allGroupMembers: allGroupMembers,
closedGroup: closedGroup,
thread: thread
)
}
.map { _ in }
return promise
}
@objc(leaveClosedGroupWithPublicKey:using:)
public static func objc_leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> AnyPromise {
return AnyPromise.from(leave(groupPublicKey, using: transaction))
@objc(leaveClosedGroupWithPublicKey:)
public static func objc_leave(_ groupPublicKey: String) -> AnyPromise {
let promise = GRDBStorage.shared.write { db in
try leave(db, groupPublicKey: groupPublicKey)
}
return AnyPromise.from(promise)
}
/// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the user is a regular
/// member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave message). The admin can then truly
/// remove them later.
/// Leave the group with the given `groupPublicKey`. If the current user is the admin, the group is disbanded entirely. If the
/// user is a regular member they'll be marked as a "zombie" member by the other users in the group (upon receiving the leave
/// message). The admin can then truly remove them later.
///
/// This function also removes all encryption key pairs associated with the closed group and the group's public key, and unregisters from push notifications.
/// This function also removes all encryption key pairs associated with the closed group and the group's public key, and
/// unregisters from push notifications.
///
/// The returned promise is fulfilled when the `MEMBER_LEFT` message has been sent to the group.
public static func leave(_ groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) -> Promise<Void> {
// Get the group, check preconditions & prepare
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let thread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
public static func leave(_ db: Database, groupPublicKey: String) throws -> Promise<Void> {
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
SNLog("Can't leave nonexistent closed group.")
return Promise(error: Error.noThread)
return Promise(error: MessageSenderError.noThread)
}
let group = thread.groupModel
let userPublicKey = getUserHexEncodedPublicKey()
let isCurrentUserAdmin = group.groupAdminIds.contains(userPublicKey)
let members: Set<String> = isCurrentUserAdmin ? [] : Set(group.groupMemberIds).subtracting([ userPublicKey ]) // If the admin leaves the group is disbanded
let admins: Set<String> = isCurrentUserAdmin ? [] : Set(group.groupAdminIds)
// Send the update to the group
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .memberLeft)
let promise = MessageSender.sendNonDurably(closedGroupControlMessage, in: thread, using: transaction).done {
SNMessagingKitConfiguration.shared.storage.write { transaction in
// Remove the group from the database and unsubscribe from PNs
Storage.shared.removeAllClosedGroupEncryptionKeyPairs(for: groupPublicKey, using: transaction)
Storage.shared.removeClosedGroupPublicKey(groupPublicKey, using: transaction)
ClosedGroupPoller.shared.stopPolling(for: groupPublicKey)
let _ = PushNotificationAPI.performOperation(.unsubscribe, for: groupPublicKey, publicKey: userPublicKey)
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return Promise(error: MessageSenderError.invalidClosedGroupUpdate)
}
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let isCurrentUserAdmin: Bool = allGroupMembers.contains(where: {
$0.role == .admin && $0.profileId == userPublicKey
})
let membersToRemove: [GroupMember] = allGroupMembers
.filter { member in
member.role == .standard && (
isCurrentUserAdmin || // If the admin leaves the group is disbanded
member.profileId == userPublicKey
)
}
}.map { _ in }
// Update the group
let newGroupModel = TSGroupModel(title: group.groupName, memberIds: [String](members), image: nil, groupId: groupID, groupType: .closedGroup, adminIds: [String](admins))
thread.setGroupModel(newGroupModel, with: transaction)
let adminsToRemove: [GroupMember] = allGroupMembers
.filter { member in
member.role == .admin && (
isCurrentUserAdmin || // If the admin leaves the group is disbanded
member.profileId == userPublicKey
)
}
// Notify the user
let updateInfo = group.getInfoStringAboutUpdate(to: newGroupModel)
let infoMessage = TSInfoMessage(timestamp: NSDate.ows_millisecondTimeStamp(), in: thread, messageType: .groupCurrentUserLeft, customMessage: updateInfo)
infoMessage.save(with: transaction)
let interaction: Interaction = try Interaction(
threadId: thread.id,
authorId: userPublicKey,
variant: .infoClosedGroupCurrentUserLeft,
body: ClosedGroupControlMessage.Kind
.memberLeft
.infoMessage(db, sender: userPublicKey),
timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000))
).inserted(db)
guard let interactionId: Int64 = interaction.id else {
throw GRDBStorageError.objectNotSaved
}
// Send the update to the group
let promise = try MessageSender
.sendNonDurably(
db,
message: ClosedGroupControlMessage(
kind: .memberLeft
),
interactionId: interactionId,
in: thread
)
.done {
GRDBStorage.shared.write { db in
// Remove the group from the database and unsubscribe from PNs
ClosedGroupPoller.shared.stopPolling(for: groupPublicKey)
_ = try closedGroup
.keyPairs
.deleteAll(db)
let _ = PushNotificationAPI.performOperation(
.unsubscribe,
for: groupPublicKey,
publicKey: userPublicKey
)
}
}
.map { _ in }
// Update the group
try membersToRemove.deleteAll(db)
try adminsToRemove.deleteAll(db)
// Return
return promise
}
@ -327,28 +592,66 @@ extension MessageSender {
}
*/
public static func sendLatestEncryptionKeyPair(to publicKey: String, for groupPublicKey: String, using transaction: YapDatabaseReadWriteTransaction) {
// Check that the user in question is part of the closed group
let groupID = LKGroupUtilities.getEncodedClosedGroupIDAsData(groupPublicKey)
let threadID = TSGroupThread.threadId(fromGroupId: groupID)
guard let groupThread = TSGroupThread.fetch(uniqueId: threadID, transaction: transaction) else {
public static func sendLatestEncryptionKeyPair(_ db: Database, to publicKey: String, for groupPublicKey: String) {
guard let thread: SessionThread = try? SessionThread.fetchOne(db, id: groupPublicKey) else {
return SNLog("Couldn't send key pair for nonexistent closed group.")
}
let group = groupThread.groupModel
guard group.groupMemberIds.contains(publicKey) else {
guard let closedGroup: ClosedGroup = try? thread.closedGroup.fetchOne(db) else {
return
}
guard let allGroupMembers: [GroupMember] = try? closedGroup.allMembers.fetchAll(db) else {
return
}
guard allGroupMembers.contains(where: { $0.role == .standard && $0.profileId == publicKey }) else {
return SNLog("Refusing to send latest encryption key pair to non-member.")
}
// Get the latest encryption key pair
guard let encryptionKeyPair = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last
?? Storage.shared.getLatestClosedGroupEncryptionKeyPair(for: groupPublicKey) else { return }
var maybeEncryptionKeyPair: Box.KeyPair? = distributingClosedGroupEncryptionKeyPairs[groupPublicKey]?.last
if maybeEncryptionKeyPair == nil {
guard let encryptionKeyPair: ClosedGroupKeyPair = try? closedGroup.fetchLatestKeyPair(db) else {
return
}
maybeEncryptionKeyPair = Box.KeyPair(
publicKey: Data(hex: encryptionKeyPair.publicKey).bytes,
secretKey: encryptionKeyPair.secretKey.bytes
)
}
guard let encryptionKeyPair: Box.KeyPair = maybeEncryptionKeyPair else { return }
// Send it
guard let proto = try? SNProtoKeyPair.builder(publicKey: Data(encryptionKeyPair.publicKey),
privateKey: Data(encryptionKeyPair.secretKey)).build(), let plaintext = try? proto.serializedData() else { return }
let contactThread = TSContactThread.getOrCreateThread(withContactSessionID: publicKey, transaction: transaction)
guard let ciphertext = try? MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey) else { return }
SNLog("Sending latest encryption key pair to: \(publicKey).")
let wrapper = ClosedGroupControlMessage.KeyPairWrapper(publicKey: publicKey, encryptedKeyPair: ciphertext)
let closedGroupControlMessage = ClosedGroupControlMessage(kind: .encryptionKeyPair(publicKey: Data(hex: groupPublicKey), wrappers: [ wrapper ]))
MessageSender.send(closedGroupControlMessage, in: contactThread, using: transaction)
do {
let proto = try SNProtoKeyPair.builder(
publicKey: Data(encryptionKeyPair.publicKey),
privateKey: Data(encryptionKeyPair.secretKey)
).build()
let plaintext = try proto.serializedData()
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: publicKey, variant: .contact)
.saved(db)
let ciphertext = try MessageSender.encryptWithSessionProtocol(plaintext, for: publicKey)
SNLog("Sending latest encryption key pair to: \(publicKey).")
try MessageSender.send(
db,
message: ClosedGroupControlMessage(
kind: .encryptionKeyPair(
publicKey: Data(hex: groupPublicKey),
wrappers: [
ClosedGroupControlMessage.KeyPairWrapper(
publicKey: publicKey,
encryptedKeyPair: ciphertext
)
]
)
),
interactionId: nil,
in: thread
)
}
catch {}
}
}

View File

@ -0,0 +1,176 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import SessionUtilitiesKit
extension MessageSender {
// MARK: Durable
public static func send(_ db: Database, interaction: Interaction, with attachments: [SignalAttachment], in thread: SessionThread) throws {
guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved }
// TODO: Is the 'prep' method needed anymore?
// prep(db, attachments, for: message)
try send(
db,
message: VisibleMessage.from(db, interaction: interaction),
interactionId: interactionId,
in: thread
)
}
public static func send(_ db: Database, interaction: Interaction, in thread: SessionThread) throws {
guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved }
return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread)
}
public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws {
JobRunner.add(
db,
job: Job(
variant: .messageSend,
details: MessageSendJob.Details(
interactionId: interactionId,
destination: try Message.Destination.from(db, thread: thread),
message: message
)
)
)
}
public static func sendNonDurably(_ db: Database, interaction: Interaction, in thread: SessionThread) -> Promise<Void> {
guard let interactionId: Int64 = interaction.id else {
return Promise(error: GRDBStorageError.objectNotSaved)
}
let attachments: [Attachment]? = try? Attachment
.filter(Attachment.Columns.state == Attachment.State.pending)
.joining(
required: Attachment.interactionAttachments
.filter(InteractionAttachment.Columns.interactionId == interactionId)
)
.fetchAll(db)
let attachmentUploadPromises: [Promise<Void>] = (attachments ?? [])
.map { attachment in
let (promise, seal) = Promise<Void>.pending()
if let openGroup: OpenGroup = try? thread.openGroup.fetchOne(db) {
AttachmentUploadJob.upload(
db,
attachment: attachment,
using: { data in OpenGroupAPIV2.upload(data, to: openGroup.room, on: openGroup.server) },
encrypt: false,
success: { seal.fulfill(()) },
failure: { seal.reject($0) }
)
}
else {
AttachmentUploadJob.upload(
db,
attachment: attachment,
using: FileServerAPIV2.upload,
encrypt: true,
success: { seal.fulfill(()) },
failure: { seal.reject($0) }
)
}
return promise
}
return when(resolved: attachmentUploadPromises)
.then(on: DispatchQueue.global(qos: .userInitiated)) { results -> Promise<Void> in
let errors = results
.compactMap { result -> Swift.Error? in
if case .rejected(let error) = result { return error }
return nil
}
if let error = errors.first { return Promise(error: error) }
return sendNonDurably(db, interaction: interaction, in: thread)
}
}
public static func sendNonDurably(_ db: Database, _ message: VisibleMessage, with attachmentIds: [String], in thread: TSThread) -> Promise<Void> {
}
public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise<Void> {
return try MessageSender.send(
db,
message: message,
to: try Message.Destination.from(db, thread: thread),
interactionId: interactionId
)
}
public static func sendNonDurably(_ message: VisibleMessage, with attachments: [SignalAttachment], in thread: TSThread) -> Promise<Void> {
}
public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise<Void> {
return try MessageSender.send(
db,
message: message,
to: destination,
interactionId: interactionId
)
}
/// This method requires the `db` value to be passed in because if it's called within a `writeAsync` completion block
/// it will throw a "re-entrant" fatal error when attempting to write again
public static func syncConfiguration(_ db: Database, forceSyncNow: Bool = true) throws -> Promise<Void> {
// If we don't have a userKeyPair yet then there is no need to sync the configuration
// as the user doesn't exist yet (this will get triggered on the first launch of a
// fresh install due to the migrations getting run)
guard Identity.userExists(db) else {
return Promise(error: GRDBStorageError.generic)
}
let destination: Message.Destination = Message.Destination.contact(
publicKey: getUserHexEncodedPublicKey(db)
)
let configurationMessage = try ConfigurationMessage.getCurrent(db)
let (promise, seal) = Promise<Void>.pending()
if forceSyncNow {
try MessageSender
.send(db, message: configurationMessage, to: destination, interactionId: nil)
.done { seal.fulfill(()) }
.catch { _ in seal.reject(GRDBStorageError.generic) }
.retainUntilComplete()
}
else {
JobRunner.add(
db,
job: Job(
variant: .messageSend,
details: MessageSendJob.Details(
interactionId: nil,
destination: destination,
message: configurationMessage
)
)
)
seal.fulfill(())
}
return promise
}
}
extension MessageSender {
@objc(forceSyncConfigurationNow)
public static func objc_forceSyncConfigurationNow() {
GRDBStorage.shared.write { db in
try syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
}
}

View File

@ -7,14 +7,24 @@ import SessionUtilitiesKit
extension MessageSender {
internal static func encryptWithSessionProtocol(_ plaintext: Data, for recipientHexEncodedX25519PublicKey: String) throws -> Data {
guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else { throw Error.noUserED25519KeyPair }
guard let userED25519KeyPair = Identity.fetchUserEd25519KeyPair() else {
throw MessageSenderError.noUserED25519KeyPair
}
let recipientX25519PublicKey = Data(hex: recipientHexEncodedX25519PublicKey.removing05PrefixIfNeeded())
let sodium = Sodium()
let verificationData = plaintext + Data(userED25519KeyPair.publicKey) + recipientX25519PublicKey
guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else { throw Error.signingFailed }
guard let signature = sodium.sign.signature(message: Bytes(verificationData), secretKey: userED25519KeyPair.secretKey) else {
throw MessageSenderError.signingFailed
}
let plaintextWithMetadata = plaintext + Data(userED25519KeyPair.publicKey) + Data(signature)
guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else { throw Error.encryptionFailed }
guard let ciphertext = sodium.box.seal(message: Bytes(plaintextWithMetadata), recipientPublicKey: Bytes(recipientX25519PublicKey)) else {
throw MessageSenderError.encryptionFailed
}
return Data(ciphertext)
}

Some files were not shown because too many files have changed in this diff Show More