mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
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:
parent
28553b218b
commit
11231599db
135 changed files with 9073 additions and 3778 deletions
1
Podfile
1
Podfile
|
@ -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
|
||||
|
|
|
@ -219,6 +219,6 @@ SPEC CHECKSUMS:
|
|||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
||||
|
||||
PODFILE CHECKSUM: 33a5ecfe231383831bf212de4ff6c99c047c344a
|
||||
PODFILE CHECKSUM: 50ae96076a7cd581c63b3276679615844c88ac44
|
||||
|
||||
COCOAPODS: 1.11.2
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(§ionChanges, 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(§ionChanges, 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()
|
||||
|
|
119
Session/Home/HomeViewModel.swift
Normal file
119
Session/Home/HomeViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
//
|
||||
|
||||
#import "SignalApp.h"
|
||||
#import "AppDelegate.h"
|
||||
#import "Session-Swift.h"
|
||||
#import <SignalCoreKit/Threading.h>
|
||||
#import <SessionMessagingKit/Environment.h>
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
#import "AppDelegate.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
@autoreleasepool {
|
||||
return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class));
|
||||
}
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)!)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 couldn’t 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
218
SessionMessagingKit/Database/Models/Job.swift
Normal file
218
SessionMessagingKit/Database/Models/Job.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
|
|
@ -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()
|
||||
}
|
395
SessionMessagingKit/Jobs/JobRunner.swift
Normal file
395
SessionMessagingKit/Jobs/JobRunner.swift
Normal 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)))
|
||||
}
|
||||
}
|
12
SessionMessagingKit/Jobs/JobRunnerError.swift
Normal file
12
SessionMessagingKit/Jobs/JobRunnerError.swift
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
100
SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift
Normal file
100
SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
29
SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift
Normal file
29
SessionMessagingKit/Jobs/Types/FailedMessagesJob.swift
Normal 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)
|
||||
}
|
||||
}
|
74
SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift
Normal file
74
SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift
Normal 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
|
||||
}
|
||||
}
|
350
SessionMessagingKit/Jobs/Types/MessageSendJob.swift
Normal file
350
SessionMessagingKit/Jobs/Types/MessageSendJob.swift
Normal 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
|
||||
// }
|
||||
//}
|
||||
//
|
66
SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift
Normal file
66
SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift
Normal 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
|
||||
}
|
||||
}
|
136
SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift
Normal file
136
SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift
Normal 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc(SNControlMessage)
|
||||
public class ControlMessage : Message { }
|
||||
public class ControlMessage: Message { }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
|
||||
public extension VisibleMessage {
|
||||
|
||||
@objc(SNMessageContact)
|
||||
class Contact : NSObject, NSCoding {
|
||||
|
||||
public required init?(coder: NSCoder) { }
|
||||
|
||||
public func encode(with coder: NSCoder) { }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue