diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 4362bbfb8f..729c480eb9 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -203,7 +203,6 @@ B835249B25C3AB650089A44F /* VisibleMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B835249A25C3AB650089A44F /* VisibleMessageCell.swift */; }; B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83524A425C3BA4B0089A44F /* InfoMessageCell.swift */; }; B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */; }; - B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B849789525D4A2F500D0D0B3 /* LinkPreviewView.swift */; }; B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84A89BB25DE328A0040017D /* ProfilePictureVC.swift */; }; B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B85357BE23A1AE0800AAF6CD /* SeedReminderView.swift */; }; @@ -719,6 +718,7 @@ FD6DF00B2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */; }; FD6E4C8A2A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */; }; FD705A92278D051200F16121 /* ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD705A91278D051200F16121 /* ReusableView.swift */; }; + FD70F25C2DC1F184003729B7 /* _026_MessageDeduplicationTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */; }; FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */; }; FD7115F228C6CB3900B47552 /* _010_AddThreadIdToFTS.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */; }; FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD7115F328C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift */; }; @@ -811,8 +811,10 @@ FD8A5B112DBF34BD004C689B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD8A5B182DBF47E9004C689B /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; FD8A5B1E2DBF4BBC004C689B /* ScreenLock+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */; }; - FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; FD8A5B342DC1A732004C689B /* _008_ResetUserConfigLastHashes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */; }; + FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */; }; + FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */; }; + FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */; }; FD8ECF7B29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */; }; FD8ECF7D2934293A00C0D1BB /* _013_SessionUtilChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */; }; FD8ECF7F2934298100C0D1BB /* ConfigDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */; }; @@ -824,6 +826,16 @@ FD96F3A529DBC3DC00401309 /* MessageSendJobSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */; }; FD97B2402A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */; }; FD97B2422A3FEBF30027DD57 /* UnreadMarkerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */; }; + FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC32DC304E100564172 /* MessageDeduplication.swift */; }; + FD981BC62DC3310B00564172 /* ExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC52DC3310800564172 /* ExtensionHelper.swift */; }; + FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */; }; + FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */; }; + FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */; }; + FD981BD22DC877BE00564172 /* Preferences+NotificationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD12DC877BA00564172 /* Preferences+NotificationMode.swift */; }; + FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B84664F4235022F30083A1CD /* MentionUtilities.swift */; }; + FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */; }; + FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD62DC9A61600564172 /* NotificationCategory.swift */; }; + FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */; }; FD99D0872D0FA731005D2E15 /* ThreadSafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */; }; FD99D0922D10F5EE005D2E15 /* ThreadSafeSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */; }; FD9AECA52AAA9609009B3406 /* NotificationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */; }; @@ -836,6 +848,9 @@ FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; FDAA167F2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */; }; + FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */; }; + FDB11A502DCC6ADE00BEF49F /* PriorityVisibilityInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A4F2DCC6ADD00BEF49F /* PriorityVisibilityInfo.swift */; }; + FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */; }; FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB348622BE3774000B716C2 /* BezierPathView.swift */; }; FDB3486E2BE8457F00B716C2 /* BackgroundTaskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */; }; FDB3487E2BE856C800B716C2 /* UIBezierPath+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDB3487D2BE856C800B716C2 /* UIBezierPath+Utilities.swift */; }; @@ -914,9 +929,7 @@ FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */; }; FDC438CB27BB7DB100C60D73 /* UpdateMessageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */; }; FDC438CD27BC641200C60D73 /* Set+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC438CC27BC641200C60D73 /* Set+Utilities.swift */; }; - FDC498B72AC15F7D00EDD897 /* AppNotificationCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */; }; FDC498B92AC15FE300EDD897 /* AppNotificationAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */; }; - FDC498BB2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */; }; FDC6D7602862B3F600B04575 /* Dependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC6D75F2862B3F600B04575 /* Dependencies.swift */; }; FDCD2E032A41294E00964D6A /* LegacyGroupOnlyRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */; }; FDCDB8E02811007F00352A0C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */; }; @@ -987,7 +1000,6 @@ FDE755202C9BC1A6002A2623 /* CacheConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE7551F2C9BC1A6002A2623 /* CacheConfig.swift */; }; FDE755222C9BC1BA002A2623 /* LibSessionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755212C9BC1BA002A2623 /* LibSessionError.swift */; }; FDE755242C9BC1D1002A2623 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */; }; - FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDEF57212C3CF03A00131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; FDEF57222C3CF03D00131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; FDEF57232C3CF04300131302 /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -1001,7 +1013,7 @@ FDF0B7422804EA4F004C14C5 /* _002_SetupStandardJobs.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7412804EA4F004C14C5 /* _002_SetupStandardJobs.swift */; }; FDF0B74928060D13004C14C5 /* QuotedReplyModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */; }; FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */; }; - FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */; }; + FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B7502807BA56004C14C5 /* NotificationsManagerType.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 */; }; @@ -1921,6 +1933,7 @@ FD6DF00A2ACFE40D0084BA4C /* _005_AddSnodeReveivedMessageInfoPrimaryKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _005_AddSnodeReveivedMessageInfoPrimaryKey.swift; sourceTree = ""; }; FD6E4C892A1AEE4700C7C243 /* LegacyUnsubscribeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyUnsubscribeRequest.swift; sourceTree = ""; }; FD705A91278D051200F16121 /* ReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReusableView.swift; sourceTree = ""; }; + FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _026_MessageDeduplicationTable.swift; sourceTree = ""; }; FD7115EA28C5D78E00B47552 /* ThreadSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSettingsViewModel.swift; sourceTree = ""; }; FD7115EF28C5D7DE00B47552 /* SessionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHeaderView.swift; sourceTree = ""; }; FD7115F128C6CB3900B47552 /* _010_AddThreadIdToFTS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _010_AddThreadIdToFTS.swift; sourceTree = ""; }; @@ -2003,8 +2016,10 @@ FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = ""; }; FD8A5B0F2DBF2F14004C689B /* NavBarSessionIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBarSessionIcon.swift; sourceTree = ""; }; FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScreenLock+Errors.swift"; sourceTree = ""; }; - FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; FD8A5B332DC1A726004C689B /* _008_ResetUserConfigLastHashes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _008_ResetUserConfigLastHashes.swift; sourceTree = ""; }; + FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCacheSpec.swift; sourceTree = ""; }; + FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _025_DropLegacyClosedGroupKeyPairTable.swift; sourceTree = ""; }; + FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralCacheSpec.swift; sourceTree = ""; }; FD8ECF7A29340FFD00C0D1BB /* LibSession+SessionMessagingKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LibSession+SessionMessagingKit.swift"; sourceTree = ""; }; FD8ECF7C2934293A00C0D1BB /* _013_SessionUtilChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _013_SessionUtilChanges.swift; sourceTree = ""; }; FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigDump.swift; sourceTree = ""; }; @@ -2014,6 +2029,15 @@ FD96F3A429DBC3DC00401309 /* MessageSendJobSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSendJobSpec.swift; sourceTree = ""; }; FD97B23F2A3FEB050027DD57 /* ARC4RandomNumberGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ARC4RandomNumberGenerator.swift; sourceTree = ""; }; FD97B2412A3FEBF30027DD57 /* UnreadMarkerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnreadMarkerCell.swift; sourceTree = ""; }; + FD981BC32DC304E100564172 /* MessageDeduplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplication.swift; sourceTree = ""; }; + FD981BC52DC3310800564172 /* ExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelper.swift; sourceTree = ""; }; + FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelperSpec.swift; sourceTree = ""; }; + FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicationSpec.swift; sourceTree = ""; }; + FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockExtensionHelper.swift; sourceTree = ""; }; + FD981BD12DC877BA00564172 /* Preferences+NotificationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationMode.swift"; sourceTree = ""; }; + FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MentionUtilities+DisplayName.swift"; sourceTree = ""; }; + FD981BD62DC9A61600564172 /* NotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategory.swift; sourceTree = ""; }; + FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationUserInfoKey.swift; sourceTree = ""; }; FD99D0862D0FA72E005D2E15 /* ThreadSafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafe.swift; sourceTree = ""; }; FD99D0912D10F5EB005D2E15 /* ThreadSafeSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeSpec.swift; sourceTree = ""; }; FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; @@ -2023,6 +2047,9 @@ FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnodeRequestSpec.swift; sourceTree = ""; }; FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Sound.swift"; sourceTree = ""; }; FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+NotificationPreviewType.swift"; sourceTree = ""; }; + FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationContent.swift; sourceTree = ""; }; + FDB11A4F2DCC6ADD00BEF49F /* PriorityVisibilityInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriorityVisibilityInfo.swift; sourceTree = ""; }; + FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupUrlInfo.swift; sourceTree = ""; }; FDB348622BE3774000B716C2 /* BezierPathView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; FDB3486C2BE8448500B716C2 /* SessionUtilitiesKit.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SessionUtilitiesKit.h; sourceTree = ""; }; FDB3486D2BE8457F00B716C2 /* BackgroundTaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundTaskManager.swift; sourceTree = ""; }; @@ -2094,9 +2121,7 @@ FDC438C827BB706500C60D73 /* SendDirectMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendDirectMessageRequest.swift; sourceTree = ""; }; FDC438CA27BB7DB100C60D73 /* UpdateMessageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateMessageRequest.swift; sourceTree = ""; }; FDC438CC27BC641200C60D73 /* Set+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Set+Utilities.swift"; sourceTree = ""; }; - FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationCategory.swift; sourceTree = ""; }; FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationAction.swift; sourceTree = ""; }; - FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNotificationUserInfoKey.swift; sourceTree = ""; }; FDC6D75F2862B3F600B04575 /* Dependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Dependencies.swift; sourceTree = ""; }; FDCCC6E82ABA7402002BBEF5 /* EmojiGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiGenerator.swift; sourceTree = ""; }; FDCD2E022A41294E00964D6A /* LegacyGroupOnlyRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyGroupOnlyRequest.swift; sourceTree = ""; }; @@ -2174,7 +2199,6 @@ FDE755212C9BC1BA002A2623 /* LibSessionError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibSessionError.swift; sourceTree = ""; }; FDE755232C9BC1D1002A2623 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = ""; }; - FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMessageProcessRecord.swift; sourceTree = ""; }; FDEF573D2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupUpdateMemberLeftNotificationMessage.swift; sourceTree = ""; }; FDEF57642C44B8C200131302 /* ProcessIP2CountryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessIP2CountryData.swift; sourceTree = ""; }; FDEF57662C44C1DF00131302 /* GeoLite2-Country-Blocks-IPv4.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GeoLite2-Country-Blocks-IPv4.csv"; sourceTree = ""; }; @@ -2194,7 +2218,7 @@ FDF0B7432804EF1B004C14C5 /* JobRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunner.swift; sourceTree = ""; }; FDF0B74828060D13004C14C5 /* QuotedReplyModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedReplyModel.swift; sourceTree = ""; }; FDF0B74A28061F7A004C14C5 /* InteractionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractionAttachment.swift; sourceTree = ""; }; - FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsProtocol.swift; sourceTree = ""; }; + FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManagerType.swift; sourceTree = ""; }; FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageReceiverError.swift; sourceTree = ""; }; FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSenderError.swift; sourceTree = ""; }; FDF0B75B2807F41D004C14C5 /* MessageSender+Convenience.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MessageSender+Convenience.swift"; sourceTree = ""; }; @@ -2518,6 +2542,7 @@ 7B93D07527CF1A8900811CB6 /* MockDataGenerator.swift */, 4C090A1A210FD9C7001FD7F9 /* HapticFeedback.swift */, FDD2506D283711D600198BDA /* DifferenceKit+Utilities.swift */, + FD981BD42DC978AC00564172 /* MentionUtilities+DisplayName.swift */, 45C0DC1A1E68FE9000E04C47 /* UIApplication+OWS.swift */, 45C0DC1D1E69011F00E04C47 /* UIStoryboard+OWS.swift */, 45B5360D206DD8BB00D61655 /* UIResponder+OWS.swift */, @@ -2526,7 +2551,6 @@ C3D0972A2510499C00F6E3E4 /* BackgroundPoller.swift */, C31A6C5B247F2CF3001123EF /* CGRect+Utilities.swift */, C35E8AAD2485E51D00ACB629 /* IP2Country.swift */, - B84664F4235022F30083A1CD /* MentionUtilities.swift */, B886B4A82398BA1500211ABE /* QRCode.swift */, B8783E9D23EB948D00404FB8 /* UILabel+Interaction.swift */, B83F2B87240CB75A000A54AB /* UIImage+Scaling.swift */, @@ -3073,6 +3097,7 @@ FD2272E32C35134B004D8A6C /* Data+Utilities.swift */, FD848B9728422F1A000E298B /* Date+Utilities.swift */, 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */, + B84664F4235022F30083A1CD /* MentionUtilities.swift */, FD8A5B1D2DBF4BB8004C689B /* ScreenLock+Errors.swift */, 7BA1E0E72A8087DB00123D0D /* SwiftUI+Utilities.swift */, 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */, @@ -3320,7 +3345,7 @@ children = ( FDC13D4E2A16EE41007267C7 /* Types */, FDC4382D27B383A600C60D73 /* Models */, - FDF0B7502807BA56004C14C5 /* NotificationsProtocol.swift */, + FDF0B7502807BA56004C14C5 /* NotificationsManagerType.swift */, C33FDBDE255A581900E217F9 /* PushNotificationAPI.swift */, ); path = Notifications; @@ -3394,6 +3419,7 @@ C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */, FD2273072C353109004D8A6C /* DisplayPictureManager.swift */, + FD981BC52DC3310800564172 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, @@ -3401,6 +3427,7 @@ C38EF281255B6D84007E1867 /* OWSAudioSession.swift */, FDF0B75D280AAF35004C14C5 /* Preferences.swift */, FDAA167E2AC5290000DDBF77 /* Preferences+NotificationPreviewType.swift */, + FD981BD12DC877BA00564172 /* Preferences+NotificationMode.swift */, FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */, C38EF2FB255B6DBD007E1867 /* OWSWindowManager.h */, C38EF306255B6DBE007E1867 /* OWSWindowManager.m */, @@ -3746,7 +3773,7 @@ FD09799A27FFC82D00936362 /* Quote.swift */, FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */, FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */, - FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */, + FD981BC32DC304E100564172 /* MessageDeduplication.swift */, FD5C7308285007920029977D /* BlindedIdLookup.swift */, FD09B7E6288670FD00ED0B66 /* Reaction.swift */, FD8ECF7E2934298100C0D1BB /* ConfigDump.swift */, @@ -3783,6 +3810,7 @@ FDE33BBD2D5C3AE800E56F42 /* _023_GroupsExpiredFlag.swift */, FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */, FD8A5B312DC191AB004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift */, + FD70F25B2DC1F176003729B7 /* _026_MessageDeduplicationTable.swift */, ); path = Migrations; sourceTree = ""; @@ -4051,6 +4079,7 @@ isa = PBXGroup; children = ( FD61FCF82D308CC5005752DE /* GroupMemberSpec.swift */, + FD981BCA2DC4A21800564172 /* MessageDeduplicationSpec.swift */, ); path = Models; sourceTree = ""; @@ -4252,6 +4281,7 @@ children = ( FD0B77B129B82B7A009169BA /* ArrayUtilitiesSpec.swift */, FD23CE252A676B5B0000B97C /* DependenciesSpec.swift */, + FD8A5B2F2DC18D5E004C689B /* GeneralCacheSpec.swift */, FD83B9BA27CF20AF005E1583 /* SessionIdSpec.swift */, ); path = General; @@ -4362,6 +4392,14 @@ path = Jobs; sourceTree = ""; }; + FD981BC72DC4640100564172 /* Utilities */ = { + isa = PBXGroup; + children = ( + FD981BC82DC4640D00564172 /* ExtensionHelperSpec.swift */, + ); + path = Utilities; + sourceTree = ""; + }; FDAA16792AC28E2200DDBF77 /* Models */ = { isa = PBXGroup; children = ( @@ -4400,6 +4438,9 @@ FDC13D4E2A16EE41007267C7 /* Types */ = { isa = PBXGroup; children = ( + FD981BD62DC9A61600564172 /* NotificationCategory.swift */, + FDB11A4B2DCC527900BEF49F /* NotificationContent.swift */, + FD981BD82DC9A69000564172 /* NotificationUserInfoKey.swift */, FDC13D482A16EC20007267C7 /* Service.swift */, FD3765F52ADE5BA500DC1489 /* ServiceInfo.swift */, FDD82C3E2A205D0A00425F05 /* ProcessResult.swift */, @@ -4413,6 +4454,8 @@ isa = PBXGroup; children = ( FDC1BD652CFD6C4E002CDC71 /* Config.swift */, + FDB11A512DCC6AFF00BEF49F /* OpenGroupUrlInfo.swift */, + FDB11A4F2DCC6ADD00BEF49F /* PriorityVisibilityInfo.swift */, ); path = Types; sourceTree = ""; @@ -4503,6 +4546,7 @@ FDE754A72C9B964D002A2623 /* Sending & Receiving */, FD7692F52A53A2C7000E4B70 /* Shared Models */, FD8ECF802934385900C0D1BB /* LibSession */, + FD981BC72DC4640100564172 /* Utilities */, ); path = SessionMessagingKitTests; sourceTree = ""; @@ -4527,6 +4571,7 @@ FD336F6E2CAA37CB00C0B51B /* MockCommunityPoller.swift */, FD336F582CAA28CF00C0B51B /* MockCommunityPollerCache.swift */, FD336F592CAA28CF00C0B51B /* MockDisplayPictureCache.swift */, + FD981BCC2DC81ABB00564172 /* MockExtensionHelper.swift */, FD336F5A2CAA28CF00C0B51B /* MockGroupPollerCache.swift */, FD336F5B2CAA28CF00C0B51B /* MockLibSessionCache.swift */, FD336F5C2CAA28CF00C0B51B /* MockNotificationsManager.swift */, @@ -4540,9 +4585,7 @@ FDC498B52AC15F6D00EDD897 /* Types */ = { isa = PBXGroup; children = ( - FDC498B62AC15F7D00EDD897 /* AppNotificationCategory.swift */, FDC498B82AC15FE300EDD897 /* AppNotificationAction.swift */, - FDC498BA2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift */, ); path = Types; sourceTree = ""; @@ -5768,6 +5811,7 @@ FD8A5B102DBF2F17004C689B /* NavBarSessionIcon.swift in Sources */, 942256972C23F8DD00C0FDBF /* SessionSearchBar.swift in Sources */, FD71165928E436E800B47552 /* ConfirmationModal.swift in Sources */, + FD981BD32DC9770E00564172 /* MentionUtilities.swift in Sources */, 7BBBDC44286EAD2D00747E59 /* TappableLabel.swift in Sources */, FD09B7E328865FDA00ED0B66 /* HighlightMentionBackgroundView.swift in Sources */, FD3FAB632AEB9A1500DC5421 /* ToastController.swift in Sources */, @@ -6093,9 +6137,11 @@ 7B81682828B310D50069F315 /* _007_HomeQueryOptimisationIndexes.swift in Sources */, FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */, FD2273002C352D8E004D8A6C /* LibSession+GroupKeys.swift in Sources */, + FD70F25C2DC1F184003729B7 /* _026_MessageDeduplicationTable.swift in Sources */, FD2272FB2C352D8E004D8A6C /* LibSession+UserGroups.swift in Sources */, B8856D08256F10F1001CE70E /* DeviceSleepManager.swift in Sources */, FD22726F2C32911C004D8A6C /* ProcessPendingGroupMemberRemovalsJob.swift in Sources */, + FD981BD72DC9A61A00564172 /* NotificationCategory.swift in Sources */, C300A5D32554B05A00555489 /* TypingIndicator.swift in Sources */, FDF71EA52B07363500A8D6B5 /* MessageReceiver+LibSession.swift in Sources */, FDC13D582A17207D007267C7 /* UnsubscribeResponse.swift in Sources */, @@ -6109,6 +6155,7 @@ FD71161C28D194FB00B47552 /* MentionInfo.swift in Sources */, 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */, C300A5F22554B09800555489 /* MessageSender.swift in Sources */, + FDB11A4C2DCC527D00BEF49F /* NotificationContent.swift in Sources */, FD47E0B12AA6A05800A55E41 /* Authentication+SessionMessagingKit.swift in Sources */, FD2272832C337830004D8A6C /* GroupPoller.swift in Sources */, FD22726C2C32911C004D8A6C /* GroupLeavingJob.swift in Sources */, @@ -6168,7 +6215,7 @@ FDE754F02C9BB08B002A2623 /* Crypto+Attachments.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, FDE754A12C9A60A6002A2623 /* Crypto+OpenGroupAPI.swift in Sources */, - FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, + FDF0B7512807BA56004C14C5 /* NotificationsManagerType.swift in Sources */, FD2272722C32911C004D8A6C /* FailedAttachmentDownloadsJob.swift in Sources */, FDC13D5A2A1721C5007267C7 /* LegacyNotifyRequest.swift in Sources */, FD245C59285065FC00B966DD /* ControlMessage.swift in Sources */, @@ -6180,7 +6227,6 @@ FD5C7307284F103B0029977D /* MessageReceiver+MessageRequests.swift in Sources */, C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */, FD3FAB592ADF906300DC5421 /* Profile+CurrentUser.swift in Sources */, - FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */, FDEF573E2C40F2A100131302 /* GroupUpdateMemberLeftNotificationMessage.swift in Sources */, FDE755002C9BB0FA002A2623 /* SessionEnvironment.swift in Sources */, FDB5DADC2A95D840002C8721 /* GroupUpdateMemberChangeMessage.swift in Sources */, @@ -6230,6 +6276,7 @@ FDB5DAE62A95D8B0002C8721 /* GroupUpdateDeleteMemberContentMessage.swift in Sources */, 7B5233C6290636D700F8F375 /* _018_DisappearingMessagesConfiguration.swift in Sources */, FD5C72FD284F0EC90029977D /* MessageReceiver+ExpirationTimers.swift in Sources */, + FDB11A502DCC6ADE00BEF49F /* PriorityVisibilityInfo.swift in Sources */, B8D0A25925E367AC00C1835E /* Notification+MessageReceiver.swift in Sources */, FDC1BD662CFD6C4F002CDC71 /* Config.swift in Sources */, FD245C53285065DB00B966DD /* ProximityMonitoringManager.swift in Sources */, @@ -6242,10 +6289,12 @@ FDC4380927B31D4E00C60D73 /* OpenGroupAPIError.swift in Sources */, FD2286692C37DA5500BC06F7 /* PollerType.swift in Sources */, FD860CBC2D6E7A9F00BBE29C /* _024_FixBustedInteractionVariant.swift in Sources */, + FD981BD22DC877BE00564172 /* Preferences+NotificationMode.swift in Sources */, FD22727B2C32911C004D8A6C /* MessageSendJob.swift in Sources */, FDC4382027B36ADC00C60D73 /* SOGSEndpoint.swift in Sources */, FDC438C927BB706500C60D73 /* SendDirectMessageRequest.swift in Sources */, C3A71D1F25589AC30043A11F /* WebSocketResources.pb.swift in Sources */, + FD981BC42DC304E600564172 /* MessageDeduplication.swift in Sources */, FDF0B74B28061F7A004C14C5 /* InteractionAttachment.swift in Sources */, FD09796E27FA6D0000936362 /* Contact.swift in Sources */, FD02CC142C3677E6009AB976 /* Request+OpenGroupAPI.swift in Sources */, @@ -6260,6 +6309,7 @@ FDE519F92AB802BB00450C53 /* Message+Origin.swift in Sources */, FD22727F2C32911C004D8A6C /* GetExpirationJob.swift in Sources */, FD2272792C32911C004D8A6C /* DisplayPictureDownloadJob.swift in Sources */, + FD981BD92DC9A69600564172 /* NotificationUserInfoKey.swift in Sources */, C352A2FF25574B6300338F3E /* (null) in Sources */, FD16AB612A1DD9B60083D849 /* ProfilePictureView+Convenience.swift in Sources */, B8856D11256F112A001CE70E /* OWSAudioSession.swift in Sources */, @@ -6277,6 +6327,7 @@ FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD8A5B322DC191B4004C689B /* _025_DropLegacyClosedGroupKeyPairTable.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, + FD981BC62DC3310B00564172 /* ExtensionHelper.swift in Sources */, FD2272FF2C352D8E004D8A6C /* LibSession+UserProfile.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, @@ -6302,6 +6353,7 @@ FD22726E2C32911C004D8A6C /* FailedMessageSendsJob.swift in Sources */, FDB5DAD42A9483F3002C8721 /* GroupUpdateInviteMessage.swift in Sources */, FDB5DAE22A95D8A0002C8721 /* GroupUpdateInviteResponseMessage.swift in Sources */, + FDB11A522DCC6B0000BEF49F /* OpenGroupUrlInfo.swift in Sources */, FD3559462CC1FF200088F2A9 /* _020_AddMissingWhisperFlag.swift in Sources */, FD5C72F9284F0E880029977D /* MessageReceiver+TypingIndicators.swift in Sources */, FD5C7303284F0FA50029977D /* MessageReceiver+Calls.swift in Sources */, @@ -6392,7 +6444,6 @@ FD87DCFE28B7582C00AF0F98 /* BlockedContactsViewModel.swift in Sources */, FD37E9DD28A384EB003AE748 /* PrimaryColorSelectionView.swift in Sources */, 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */, - FDC498B72AC15F7D00EDD897 /* AppNotificationCategory.swift in Sources */, FDE754B82C9B96BB002A2623 /* WebRTCSession+DataChannel.swift in Sources */, FDFDE126282D05380098B17F /* MediaInteractiveDismiss.swift in Sources */, 34BECE301F7ABCF800D7438D /* GifPickerLayout.swift in Sources */, @@ -6487,15 +6538,14 @@ FDEF57242C3CF04700131302 /* (null) in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 9422568C2C23F8C800C0FDBF /* DisplayNameScreen.swift in Sources */, - B84664F5235022F30083A1CD /* MentionUtilities.swift in Sources */, 7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */, FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, FDEF57212C3CF03A00131302 /* (null) in Sources */, 7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */, - FDC498BB2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, + FD981BD52DC978B400564172 /* MentionUtilities+DisplayName.swift in Sources */, 3496955C219B605E00DCFE74 /* ImagePickerController.swift in Sources */, 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */, C31A6C5C247F2CF3001123EF /* CGRect+Utilities.swift in Sources */, @@ -6600,6 +6650,7 @@ FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */, FD3FAB6E2AF1B28C00DC5421 /* MockFileManager.swift in Sources */, FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */, + FD8A5B302DC18D61004C689B /* GeneralCacheSpec.swift in Sources */, FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */, FD0150402CA2433D005B08A1 /* BencodeDecoderSpec.swift in Sources */, FD0150412CA2433D005B08A1 /* BencodeEncoderSpec.swift in Sources */, @@ -6666,6 +6717,8 @@ FDC2908727D7047F005DAE71 /* RoomSpec.swift in Sources */, FDE754A82C9B964D002A2623 /* MessageReceiverGroupsSpec.swift in Sources */, FD01504C2CA243CB005B08A1 /* Mock.swift in Sources */, + FD981BC92DC4641100564172 /* ExtensionHelperSpec.swift in Sources */, + FD981BCB2DC4A21C00564172 /* MessageDeduplicationSpec.swift in Sources */, FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */, FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */, FD23CE2A2A6775660000B97C /* MockCrypto.swift in Sources */, @@ -6673,6 +6726,7 @@ FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */, FD481A962CAE0AE000ECC4CF /* MockAppContext.swift in Sources */, FD0150452CA243BB005B08A1 /* LibSessionUtilSpec.swift in Sources */, + FD981BCD2DC81ABF00564172 /* MockExtensionHelper.swift in Sources */, FD336F602CAA28CF00C0B51B /* CommonSMKMockExtensions.swift in Sources */, FD336F612CAA28CF00C0B51B /* MockNotificationsManager.swift in Sources */, FD336F622CAA28CF00C0B51B /* CustomArgSummaryDescribable+SMK.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 365e30edab..5182a4cdc9 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -246,7 +246,8 @@ extension ContextMenuVC { dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: threadViewModel.currentUserSessionId, for: threadViewModel.openGroupRoomToken, - on: threadViewModel.openGroupServer + on: threadViewModel.openGroupServer, + currentUserSessionIds: (threadViewModel.currentUserSessionIds ?? []) ) ) let shouldShowEmojiActions: Bool = { diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 2a99cc14ba..134e65dac7 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -854,9 +854,7 @@ extension ConversationVC: currentMentionStartIndex = lastCharacterIndex snInputView.showMentionsUI( for: self.viewModel.mentions(), - currentUserSessionId: self.viewModel.threadData.currentUserSessionId, - currentUserBlinded15SessionId: self.viewModel.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.viewModel.threadData.currentUserBlinded25SessionId + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) } else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@ @@ -868,9 +866,7 @@ extension ConversationVC: let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @ snInputView.showMentionsUI( for: self.viewModel.mentions(for: query), - currentUserSessionId: self.viewModel.threadData.currentUserSessionId, - currentUserBlinded15SessionId: self.viewModel.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.viewModel.threadData.currentUserBlinded25SessionId + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) } } @@ -1473,7 +1469,8 @@ extension ConversationVC: shouldShowClearAllButton: viewModel.dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: self.viewModel.threadData.currentUserSessionId, for: self.viewModel.threadData.openGroupRoomToken, - on: self.viewModel.threadData.openGroupServer + on: self.viewModel.threadData.openGroupServer, + currentUserSessionIds: (self.viewModel.threadData.currentUserSessionIds ?? []) ) ) reactionListSheet.modalPresentationStyle = .overFullScreen @@ -1664,7 +1661,7 @@ extension ConversationVC: guard !remove else { return try? Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.authorId == cellViewModel.currentUserSessionId) + .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) .fetchOne(db) } @@ -1690,7 +1687,7 @@ extension ConversationVC: if remove { try Reaction .filter(Reaction.Columns.interactionId == cellViewModel.id) - .filter(Reaction.Columns.authorId == cellViewModel.currentUserSessionId) + .filter((cellViewModel.currentUserSessionIds ?? []).contains(Reaction.Columns.authorId)) .filter(Reaction.Columns.emoji == emoji) .deleteAll(db) } @@ -2028,9 +2025,7 @@ extension ConversationVC: timestampMs: cellViewModel.timestampMs, attachments: cellViewModel.attachments, linkPreviewAttachment: cellViewModel.linkPreviewAttachment, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []) ) guard let quoteDraft: QuotedReplyModel = maybeQuoteDraft else { return } diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index 1820684e45..309d8f610c 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -108,7 +108,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold }() // MARK: - Initialization - + // TODO: [Database Relocation] Initialise this with the thread data from the home screen (might mean we can avoid some of the `initialData` query? init( threadId: String, threadVariant: SessionThread.Variant, @@ -125,8 +125,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold currentUserIsClosedGroupAdmin: Bool?, openGroupPermissions: OpenGroup.Permissions?, threadWasMarkedUnread: Bool, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? + currentUserSessionIds: Set ) let initialData: InitialData? = dependencies[singleton: .storage].read { db -> InitialData in @@ -216,20 +215,28 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .asRequest(of: Bool.self) .fetchOne(db)) .defaulting(to: false) - let blinded15SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - using: dependencies - ) - let blinded25SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - using: dependencies - ) + var currentUserSessionIds: Set = Set([userSessionId.hexString]) + + if + threadVariant == .community, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + { + currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString) + currentUserSessionIds = currentUserSessionIds.inserting(SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString) + } return ( userSessionId, @@ -241,8 +248,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold currentUserIsClosedGroupAdmin, openGroupPermissions, threadWasMarkedUnread, - blinded15SessionId, - blinded25SessionId + currentUserSessionIds ) } @@ -264,18 +270,23 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold threadWasMarkedUnread: initialData?.threadWasMarkedUnread, using: dependencies ).populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: initialData?.blinded15SessionId?.hexString, - currentUserBlinded25SessionIdForThisThread: initialData?.blinded25SessionId?.hexString, + currentUserSessionIds: ( + initialData?.currentUserSessionIds ?? + [dependencies[cache: .general].sessionId.hexString] + ), wasKickedFromGroup: ( threadVariant == .group && - LibSession.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId), using: dependencies) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) + } ), groupIsDestroyed: ( threadVariant == .group && - LibSession.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId), using: dependencies) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + } ), - threadCanWrite: true, // Assume true - using: dependencies + threadCanWrite: true // Assume true ) self.pagedDataObserver = nil self.dependencies = dependencies @@ -287,8 +298,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold self.pagedDataObserver = self.setupPagedObserver( for: threadId, userSessionId: (initialData?.userSessionId ?? dependencies[cache: .general].sessionId), - blinded15SessionId: initialData?.blinded15SessionId, - blinded25SessionId: initialData?.blinded25SessionId, + currentUserSessionIds: ( + initialData?.currentUserSessionIds ?? + [dependencies[cache: .general].sessionId.hexString] + ), using: dependencies ) @@ -342,27 +355,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold .map { viewModel -> SessionThreadViewModel in let wasKickedFromGroup: Bool = ( viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ) let groupIsDestroyed: Bool = ( viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ) return viewModel.populatingPostQueryData( - db, - currentUserBlinded15SessionIdForThisThread: self?.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: self?.threadData.currentUserBlinded25SessionId, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed, - threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies), - using: dependencies + threadCanWrite: viewModel.determineInitialCanWriteFlag(using: dependencies) ) } } @@ -436,8 +447,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold private func setupPagedObserver( for threadId: String, userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId?, + currentUserSessionIds: Set, using dependencies: Dependencies ) -> PagedDatabaseObserver { return PagedDatabaseObserver( @@ -505,8 +515,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold orderSQL: MessageViewModel.orderSQL, dataQuery: MessageViewModel.baseQuery( userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId, + currentUserSessionIds: currentUserSessionIds, orderSQL: MessageViewModel.orderSQL, groupSQL: MessageViewModel.groupSQL ), @@ -562,14 +571,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ], dataQuery: MessageViewModel.QuotedInfo.baseQuery( userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId - ), - joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL( - userSessionId: userSessionId, - blinded15SessionId: blinded15SessionId, - blinded25SessionId: blinded25SessionId + currentUserSessionIds: currentUserSessionIds ), + joinToPagedType: MessageViewModel.QuotedInfo.joinToViewModelQuerySQL(), retrieveRowIdsForReferencedRowIds: MessageViewModel.QuotedInfo.createReferencedRowIdsRetriever(), associateData: MessageViewModel.QuotedInfo.createAssociateDataClosure() ) @@ -635,16 +639,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold ), isLastOutgoing: ( cellViewModel.id == sortedData - .filter { - $0.authorId == threadData.currentUserSessionId || - $0.authorId == threadData.currentUserBlinded15SessionId || - $0.authorId == threadData.currentUserBlinded25SessionId - } + .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } .last? .id ), - currentUserBlinded15SessionId: threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: threadData.currentUserBlinded25SessionId, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []), using: dependencies ) } @@ -714,16 +713,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold let interaction: Interaction = Interaction( threadId: threadData.threadId, threadVariant: threadData.threadVariant, - authorId: (threadData.currentUserBlinded15SessionId ?? threadData.currentUserSessionId), + authorId: (threadData.currentUserSessionIds ?? []) + .first { $0.hasPrefix(SessionId.Prefix.blinded15.rawValue) } + .defaulting(to: threadData.currentUserSessionId), variant: .standardOutgoing, body: text, timestampMs: sentTimestampMs, hasMention: Interaction.isUserMentioned( - publicKeysToCheck: [ - threadData.currentUserSessionId, - threadData.currentUserBlinded15SessionId, - threadData.currentUserBlinded25SessionId - ].compactMap { $0 }, + publicKeysToCheck: (threadData.currentUserSessionIds ?? []), body: text ), expiresInSeconds: threadData.disappearingMessagesConfiguration?.expiresInSeconds(), @@ -769,7 +766,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: threadData.currentUserSessionId, for: threadData.openGroupRoomToken, - on: threadData.openGroupServer + on: threadData.openGroupServer, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []) ) default: return false @@ -915,12 +913,13 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold return (try MentionInfo .query( - userPublicKey: userSessionId.hexString, threadId: threadData.threadId, threadVariant: threadData.threadVariant, targetPrefixes: targetPrefixes, - currentUserBlinded15SessionId: self?.threadData.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self?.threadData.currentUserBlinded25SessionId, + currentUserSessionIds: ( + self?.threadData.currentUserSessionIds ?? + [userSessionId.hexString] + ), pattern: pattern )? .fetchAll(db)) @@ -1017,8 +1016,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate, NavigatableStateHold self.pagedDataObserver = self.setupPagedObserver( for: updatedThreadId, userSessionId: dependencies[cache: .general].sessionId, - blinded15SessionId: nil, - blinded25SessionId: nil, + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], using: dependencies ) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 7dfed49474..dbdeb16a85 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -279,9 +279,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M authorId: quoteDraftInfo.model.authorId, quotedText: quoteDraftInfo.model.body, threadVariant: threadVariant, - currentUserSessionId: quoteDraftInfo.model.currentUserSessionId, - currentUserBlinded15SessionId: quoteDraftInfo.model.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: quoteDraftInfo.model.currentUserBlinded25SessionId, + currentUserSessionIds: quoteDraftInfo.model.currentUserSessionIds, direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), attachment: quoteDraftInfo.model.attachment, using: dependencies @@ -539,13 +537,9 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M func showMentionsUI( for candidates: [MentionInfo], - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) { - mentionsView.currentUserSessionId = currentUserSessionId - mentionsView.currentUserBlinded15SessionId = currentUserBlinded15SessionId - mentionsView.currentUserBlinded25SessionId = currentUserBlinded25SessionId + mentionsView.currentUserSessionIds = currentUserSessionIds mentionsView.candidates = candidates let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing) diff --git a/Session/Conversations/Input View/MentionSelectionView.swift b/Session/Conversations/Input View/MentionSelectionView.swift index 6bf8e7a9a3..003e0ae7bf 100644 --- a/Session/Conversations/Input View/MentionSelectionView.swift +++ b/Session/Conversations/Input View/MentionSelectionView.swift @@ -8,9 +8,7 @@ import SignalUtilitiesKit final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDelegate { private let dependencies: Dependencies - var currentUserSessionId: String? - var currentUserBlinded15SessionId: String? - var currentUserBlinded25SessionId: String? + var currentUserSessionIds: Set = [] var candidates: [MentionInfo] = [] { didSet { tableView.isScrollEnabled = (candidates.count > 4) @@ -93,11 +91,10 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele isUserModeratorOrAdmin: dependencies[singleton: .openGroupManager].isUserModeratorOrAdmin( publicKey: candidates[indexPath.row].profile.id, for: candidates[indexPath.row].openGroupRoomToken, - on: candidates[indexPath.row].openGroupServer + on: candidates[indexPath.row].openGroupServer, + currentUserSessionIds: currentUserSessionIds ), - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, isLast: (indexPath.row == (candidates.count - 1)), using: dependencies ) @@ -193,17 +190,10 @@ private extension MentionSelectionView { with profile: Profile, threadVariant: SessionThread.Variant, isUserModeratorOrAdmin: Bool, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, isLast: Bool, using dependencies: Dependencies ) { - let currentUserSessionIds: Set = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ].compactMap { $0 }.asSet() displayNameLabel.text = (currentUserSessionIds.contains(profile.id) ? "you".localized() : profile.displayName(for: threadVariant) diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index 055b217c6f..effb73fb8c 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -30,9 +30,7 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, direction: Direction, attachment: Attachment?, using dependencies: Dependencies, @@ -48,9 +46,7 @@ final class QuoteView: UIView { authorId: authorId, quotedText: quotedText, threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, direction: direction, attachment: attachment ) @@ -69,9 +65,7 @@ final class QuoteView: UIView { authorId: String, quotedText: String?, threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, direction: Direction, attachment: Attachment? ) { @@ -197,9 +191,7 @@ final class QuoteView: UIView { MentionUtilities.highlightMentions( in: $0, threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, location: { switch (mode, direction) { case (.draft, _): return .quoteDraft @@ -225,19 +217,10 @@ final class QuoteView: UIView { } // Label stack view - let isCurrentUser: Bool = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() - .contains(authorId) - let authorLabel = UILabel() authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) authorLabel.text = { - guard !isCurrentUser else { return "you".localized() } + guard !currentUserSessionIds.contains(authorId) else { return "you".localized() } guard body != nil else { // When we can't find the quoted message we want to hide the author label return Profile.displayNameNoFallback( diff --git a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift index c35beabfb6..fe2df20e33 100644 --- a/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift +++ b/Session/Conversations/Message Cells/Content Views/SwiftUI/QuoteView_SwiftUI.swift @@ -13,9 +13,7 @@ struct QuoteView_SwiftUI: View { var authorId: String var quotedText: String? var threadVariant: SessionThread.Variant - var currentUserSessionId: String? - var currentUserBlinded15SessionId: String? - var currentUserBlinded25SessionId: String? + var currentUserSessionIds: Set var direction: Direction var attachment: Attachment? } @@ -33,16 +31,7 @@ struct QuoteView_SwiftUI: View { private var info: Info private var onCancel: (() -> ())? - private var isCurrentUser: Bool { - return [ - info.currentUserSessionId, - info.currentUserBlinded15SessionId, - info.currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() - .contains(info.authorId) - } + private var isCurrentUser: Bool { info.currentUserSessionIds.contains(info.authorId) } private var quotedText: String? { if let quotedText = info.quotedText, !quotedText.isEmpty { return quotedText @@ -173,9 +162,7 @@ struct QuoteView_SwiftUI: View { MentionUtilities.highlightMentions( in: quotedText, threadVariant: info.threadVariant, - currentUserSessionId: info.currentUserSessionId, - currentUserBlinded15SessionId: info.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: info.currentUserBlinded25SessionId, + currentUserSessionIds: info.currentUserSessionIds, location: { switch (info.mode, info.direction) { case (.draft, _): return .quoteDraft @@ -236,6 +223,7 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { mode: .draft, authorId: "", threadVariant: .contact, + currentUserSessionIds: [], direction: .outgoing ), using: Dependencies.createEmpty() @@ -247,6 +235,7 @@ struct QuoteView_SwiftUI_Previews: PreviewProvider { mode: .regular, authorId: "", threadVariant: .contact, + currentUserSessionIds: [], direction: .incoming, attachment: Attachment( variant: .standard, diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index 828449e17b..920fdd4829 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -550,9 +550,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -628,9 +626,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -680,9 +676,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { authorId: quote.authorId, quotedText: quote.body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), direction: (cellViewModel.variant.isOutgoing ? .outgoing : .incoming), attachment: cellViewModel.quoteAttachment, using: dependencies @@ -766,7 +760,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { return } - let isSelfSend: Bool = (reactionInfo.reaction.authorId == cellViewModel.currentUserSessionId) + let isSelfSend: Bool = (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) if let value: ReactionViewModel = result.value(forKey: emoji) { result.replace( @@ -1181,9 +1175,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { attributedString: MentionUtilities.highlightMentions( in: body, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), location: (isOutgoing ? .outgoingMessage : .incomingMessage), textColor: actualTextColor, theme: theme, diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 47607b6937..003db52bff 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -844,7 +844,7 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob request: Contact .filter(Contact.Columns.isApproved == true) .filter(Contact.Columns.didApproveMe == true) - .filter(Contact.Columns.id != threadViewModel.currentUserSessionId), + .filter(!(threadViewModel.currentUserSessionIds ?? []).contains(Contact.Columns.id)), footerTitle: "membersInvite".localized(), footerAccessibility: Accessibility( identifier: "Invite contacts button" diff --git a/Session/Conversations/Views & Modals/ReactionListSheet.swift b/Session/Conversations/Views & Modals/ReactionListSheet.swift index 1ff94d667d..53885a54cb 100644 --- a/Session/Conversations/Views & Modals/ReactionListSheet.swift +++ b/Session/Conversations/Views & Modals/ReactionListSheet.swift @@ -229,7 +229,7 @@ final class ReactionListSheet: BaseVC { return } - if reactionInfo.reaction.authorId == cellViewModel.currentUserSessionId { + if (cellViewModel.currentUserSessionIds ?? []).contains(reactionInfo.reaction.authorId) { updatedValue.insert(reactionInfo, at: 0) } else { @@ -439,7 +439,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { let cellViewModel: MessageViewModel.ReactionInfo = self.selectedReactionUserList[indexPath.row] let authorId: String = cellViewModel.reaction.authorId let canRemoveEmoji: Bool = ( - authorId == self.messageViewModel.currentUserSessionId && + (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) && self.messageViewModel.threadVariant != .legacyGroup ) cell.update( @@ -462,7 +462,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { ) ), styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge), - isEnabled: (authorId == self.messageViewModel.currentUserSessionId) + isEnabled: (self.messageViewModel.currentUserSessionIds ?? []).contains(authorId) ), tableSize: tableView.bounds.size, using: dependencies @@ -483,7 +483,7 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource { .first(where: { $0.isSelected })? .emoji, selectedReaction.rawValue == cellViewModel.reaction.emoji, - cellViewModel.reaction.authorId == self.messageViewModel.currentUserSessionId + (self.messageViewModel.currentUserSessionIds ?? []).contains(cellViewModel.reaction.authorId) else { return } delegate?.removeReact(self.messageViewModel, for: selectedReaction) diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 669149ff76..7c3a4dc913 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -354,7 +354,7 @@ public final class HomeVC: BaseVC, LibSessionRespondingViewController, UITableVi // Start polling if needed (i.e. if the user just created or restored their Session ID) if - Identity.userExists(using: viewModel.dependencies), + viewModel.dependencies[cache: .general].userExists, let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate, viewModel.dependencies[singleton: .appContext].isMainAppAndActive { diff --git a/Session/Home/HomeViewModel.swift b/Session/Home/HomeViewModel.swift index 58fa8b0651..be1b1fa18f 100644 --- a/Session/Home/HomeViewModel.swift +++ b/Session/Home/HomeViewModel.swift @@ -364,28 +364,23 @@ public class HomeViewModel: NavigatableStateHolder { } .map { viewModel -> SessionThreadViewModel in viewModel.populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: groupedOldData[viewModel.threadId]? + currentUserSessionIds: (groupedOldData[viewModel.threadId]? .first? - .currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: groupedOldData[viewModel.threadId]? - .first? - .currentUserBlinded25SessionId, + .currentUserSessionIds) + .defaulting(to: [dependencies[cache: .general].sessionId.hexString]), wasKickedFromGroup: ( viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), groupIsDestroyed: ( viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), - threadCanWrite: false, // Irrelevant for the HomeViewModel - using: dependencies + threadCanWrite: false // Irrelevant for the HomeViewModel ) } ) diff --git a/Session/Home/Message Requests/MessageRequestsViewModel.swift b/Session/Home/Message Requests/MessageRequestsViewModel.swift index 9b5628a5c7..0a016bc9dd 100644 --- a/Session/Home/Message Requests/MessageRequestsViewModel.swift +++ b/Session/Home/Message Requests/MessageRequestsViewModel.swift @@ -146,30 +146,24 @@ class MessageRequestsViewModel: SessionTableViewModel, NavigatableStateHolder, O .map { [dependencies] viewModel -> SessionCell.Info in SessionCell.Info( id: viewModel.populatingPostQueryData( - currentUserBlinded15SessionIdForThisThread: groupedOldData[viewModel.threadId]? + currentUserSessionIds: (groupedOldData[viewModel.threadId]? .first? .id - .currentUserBlinded15SessionId, - currentUserBlinded25SessionIdForThisThread: groupedOldData[viewModel.threadId]? - .first? - .id - .currentUserBlinded25SessionId, + .currentUserSessionIds) + .defaulting(to: [dependencies[cache: .general].sessionId.hexString]), wasKickedFromGroup: ( viewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), groupIsDestroyed: ( viewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: viewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: viewModel.threadId)) + } ), - threadCanWrite: false, // Irrelevant for the MessageRequestsViewModel - using: dependencies + threadCanWrite: false // Irrelevant for the MessageRequestsViewModel ), accessibility: Accessibility( identifier: "Message request" diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index 6cc2cb0b45..ae8d72cfc1 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -486,9 +486,7 @@ struct MessageBubble: View { authorId: quote.authorId, quotedText: quote.body, threadVariant: messageViewModel.threadVariant, - currentUserSessionId: messageViewModel.currentUserSessionId, - currentUserBlinded15SessionId: messageViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: messageViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (messageViewModel.currentUserSessionIds ?? []), direction: (messageViewModel.variant == .standardOutgoing ? .outgoing : .incoming), attachment: messageViewModel.quoteAttachment ), diff --git a/Session/Notifications/NotificationActionHandler.swift b/Session/Notifications/NotificationActionHandler.swift index a952882bd5..890de5b15c 100644 --- a/Session/Notifications/NotificationActionHandler.swift +++ b/Session/Notifications/NotificationActionHandler.swift @@ -102,7 +102,7 @@ public class NotificationActionHandler { // MARK: - Actions func markAsRead(userInfo: [AnyHashable: Any]) -> AnyPublisher { - guard let threadId: String = userInfo[AppNotificationUserInfoKey.threadId] as? String else { + guard let threadId: String = userInfo[NotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) .eraseToAnyPublisher() } @@ -120,7 +120,7 @@ public class NotificationActionHandler { replyText: String, applicationState: UIApplication.State ) -> AnyPublisher { - guard let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { + guard let threadId = userInfo[NotificationUserInfoKey.threadId] as? String else { return Fail(error: NotificationError.failDebug("threadId was unexpectedly nil")) .eraseToAnyPublisher() } @@ -198,8 +198,8 @@ public class NotificationActionHandler { func showThread(userInfo: [AnyHashable: Any]) -> AnyPublisher { guard - let threadId = userInfo[AppNotificationUserInfoKey.threadId] as? String, - let threadVariantRaw = userInfo[AppNotificationUserInfoKey.threadVariantRaw] as? Int, + let threadId = userInfo[NotificationUserInfoKey.threadId] as? String, + let threadVariantRaw = userInfo[NotificationUserInfoKey.threadVariantRaw] as? Int, let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: threadVariantRaw) else { return showHomeVC() } diff --git a/Session/Notifications/NotificationPresenter.swift b/Session/Notifications/NotificationPresenter.swift index c6d2b3ee0d..ecb719cb70 100644 --- a/Session/Notifications/NotificationPresenter.swift +++ b/Session/Notifications/NotificationPresenter.swift @@ -14,7 +14,7 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, private static let audioNotificationsThrottleCount = 2 private static let audioNotificationsThrottleInterval: TimeInterval = 5 - private let dependencies: Dependencies + public let dependencies: Dependencies private let notificationCenter: UNUserNotificationCenter = UNUserNotificationCenter.current() @ThreadSafeObject private var notifications: [String: UNNotificationRequest] = [:] @ThreadSafeObject private var mostRecentNotifications: TruncatedList = TruncatedList(maxLength: NotificationPresenter.audioNotificationsThrottleCount) @@ -52,353 +52,154 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, }.eraseToAnyPublisher() } + // MARK: - Unique Logic + + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [ + NotificationUserInfoKey.threadId: threadId, + NotificationUserInfoKey.threadVariantRaw: threadVariant.rawValue + ] + } + + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + guard applicationState == .active else { return true } + guard dependencies[singleton: .storage, key: .playNotificationSoundInForeground] else { return false } + + let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) + let recentThreshold = nowMs - UInt64(NotificationPresenter.audioNotificationsThrottleInterval * 1000) + + let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } + + guard recentNotifications.count < NotificationPresenter.audioNotificationsThrottleCount else { return false } + + _mostRecentNotifications.performUpdate { $0.appending(nowMs) } + return true + } + // MARK: - Presentation - public func notifyUser( + public func notifyForFailedSend( _ db: Database, - for interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // Ensure we should be showing a notification for the thread - guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest, using: dependencies) else { - return - } - - // Try to group notifications for interactions from open groups - let identifier: String = Interaction.notificationIdentifier( - for: (interaction.id ?? 0), - threadId: thread.id, - shouldGroupMessagesForThread: (thread.variant == .community) - ) - - // While batch processing, some of the necessary changes have not been commited. - let rawMessageText: String = Interaction.notificationPreviewText(db, interaction: interaction, using: dependencies) - - // 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: String? = String.filterNotificationText(rawMessageText) - let notificationTitle: String? - var notificationBody: String? - - let senderName = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - let groupName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db) - ) - - switch previewType { - case .noNameNoPreview: - notificationTitle = Constants.app_name - - case .nameNoPreview, .nameAndPreview: - switch (thread.variant, isMessageRequest) { - case (.contact, true), (.group, true): notificationTitle = Constants.app_name - case (.contact, false): notificationTitle = senderName - - case (.legacyGroup, _), (.group, false), (.community, _): - notificationTitle = "notificationsIosGroup" - .put(key: "name", value: senderName) - .put(key: "conversation_name", value: groupName) - .localized() - } - } - - switch previewType { - case .noNameNoPreview, .nameNoPreview: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() - case .nameAndPreview: notificationBody = messageText - } - - // 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 = "messageRequestsNew".localized() - } - - guard notificationBody != nil || notificationTitle != nil else { - Log.info("AppNotifications error: No notification content") - return + let notificationSettings: Preferences.NotificationSettings = dependencies.mutate(cache: .libSession) { cache in + cache.notificationSettings( + threadId: thread.id, + threadVariant: thread.variant, + openGroupUrlInfo: nil /// Communities current don't support PNs + ) } - // Don't reply from lockscreen if anyone in this conversation is - // "no longer verified". - let category = AppNotificationCategory.incomingMessage - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let userBlinded15SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: thread.id, - threadVariant: thread.variant, - blindingPrefix: .blinded15, - using: dependencies - ) - let userBlinded25SessionId: SessionId? = SessionThread.getCurrentUserBlindedSessionId( - db, + var content: NotificationContent = NotificationContent( threadId: thread.id, threadVariant: thread.variant, - blindingPrefix: .blinded25, - using: dependencies - ) - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - - let sound: Preferences.Sound? = requestSound( - thread: thread, - fallbackSound: fallbackSound, + identifier: thread.id, + category: .errorMessage, + body: "messageErrorDelivery".localized(), + sound: notificationSettings.sound, + userInfo: notificationUserInfo(threadId: thread.id, threadVariant: thread.variant), applicationState: applicationState ) - notificationBody = MentionUtilities.highlightMentionsNoAttributes( - in: (notificationBody ?? ""), - threadVariant: thread.variant, - currentUserSessionId: userSessionId.hexString, - currentUserBlinded15SessionId: userBlinded15SessionId?.hexString, - currentUserBlinded25SessionId: userBlinded25SessionId?.hexString, - using: dependencies - ) - - notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: groupName, - applicationState: applicationState, - replacingIdentifier: identifier - ) - } - - public func notifyUser( - _ db: Database, - forIncomingCall interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - // No call notifications for muted or group threads - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard - interaction.variant == .infoCall, - let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), - let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( - CallMessage.MessageInfo.self, - from: infoMessageData - ) - else { return } - - // Only notify missed calls - switch messageInfo.state { - case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: return + /// Add the title if needed + switch notificationSettings.previewType { + case .noNameNoPreview: content = content.with(title: Constants.app_name) + case .nameNoPreview, .nameAndPreview: + content = content.with( + title: dependencies.mutate(cache: .libSession) { cache in + cache.conversationDisplayName( + threadId: thread.id, + threadVariant: thread.variant, + contactProfile: (thread.variant != .contact ? nil : + try? Profile.fetchOne(db, id: thread.id) + ), + visibleMessage: nil, /// This notification is unrelated to the received message + openGroupName: (thread.variant != .community ? nil : + try? thread.openGroup + .select(.name) + .asRequest(of: String.self) + .fetchOne(db) + ), + openGroupUrlInfo: (thread.variant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: thread.id) + ) + ) + } + ) } - let category = AppNotificationCategory.errorMessage - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - - let notificationTitle: String = Constants.app_name - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let notificationBody: String? = { - switch messageInfo.state { - case .permissionDenied: - return "callsYouMissedCallPermissions" - .put(key: "name", value: senderName) - .localizedDeformatted() - case .permissionDeniedMicrophone, .missed: - return "callsMissedCallFrom" - .put(key: "name", value: senderName) - .localized() - default: - return nil - } - }() - - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - - notify( - category: category, - title: notificationTitle, - body: (notificationBody ?? ""), - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: senderName, - applicationState: applicationState, - replacingIdentifier: UUID().uuidString + addNotificationRequest( + content: content, + notificationSettings: notificationSettings ) } - public func notifyUser( - _ db: Database, - forReaction reaction: Reaction, - in thread: SessionThread, - applicationState: UIApplication.State + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true + var trigger: UNNotificationTrigger? + let shouldPresentNotification: Bool = shouldPresentNotification( + threadId: content.threadId, + category: content.category, + applicationState: content.applicationState, + using: dependencies + ) + let mutableContent: UNMutableNotificationContent = content.toMutableContent( + shouldPlaySound: notificationShouldPlaySound(applicationState: content.applicationState) ) - // No reaction notifications for muted, group threads or message requests - guard dependencies.dateNow.timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard !isMessageRequest else { return } - - let notificationTitle = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant, using: dependencies) - var notificationBody = "emojiReactsNotification" - .put(key: "emoji", value: reaction.emoji) - .localized() - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - switch previewType { - case .nameAndPreview: break - default: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() + switch shouldPresentNotification { + case true: + let shouldGroupNotification: Bool = ( + content.threadVariant == .community && + content.identifier == content.threadId + ) + + if shouldGroupNotification { + trigger = UNTimeIntervalNotificationTrigger( + timeInterval: Notifications.delayForGroupedNotifications, + repeats: false + ) + + let numberExistingNotifications: Int? = notifications[content.identifier]? + .content + .userInfo[NotificationUserInfoKey.threadNotificationCounter] + .asType(Int.self) + var numberOfNotifications: Int = (numberExistingNotifications ?? 1) + + if numberExistingNotifications != nil { + numberOfNotifications += 1 // Add one for the current notification + mutableContent.body = "messageNewYouveGot" + .putNumber(numberOfNotifications) + .localized() + } + + mutableContent.userInfo[NotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications + } + + case false: + // Play sound and vibrate, but without a `body` no banner will show + mutableContent.body = "" + Log.debug("supressing notification body") } - let category = AppNotificationCategory.incomingMessage - - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - - let threadName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: nil, // Not supported - openGroupName: nil // Not supported - ) - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - - notify( - category: category, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - applicationState: applicationState, - replacingIdentifier: UUID().uuidString - ) - } - - public func notifyForFailedSend( - _ db: Database, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - let notificationTitle: String? - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - let threadName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: try? thread.closedGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - openGroupName: try? thread.openGroup - .select(.name) - .asRequest(of: String.self) - .fetchOne(db), - isNoteToSelf: (thread.isNoteToSelf(db, using: dependencies) == true), - profile: try? Profile.fetchOne(db, id: thread.id) + let request = UNNotificationRequest( + identifier: content.identifier, + content: mutableContent, + trigger: trigger ) + + Log.debug("presenting notification with identifier: \(content.identifier)") - switch previewType { - case .noNameNoPreview: notificationTitle = nil - case .nameNoPreview, .nameAndPreview: notificationTitle = threadName + /// If we are replacing a notification then cancel the original one + if notifications[content.identifier] != nil { + cancelNotifications(identifiers: [content.identifier]) } - - let notificationBody = "messageErrorDelivery".localized() - let userInfo: [AnyHashable: Any] = [ - AppNotificationUserInfoKey.threadId: thread.id, - AppNotificationUserInfoKey.threadVariantRaw: thread.variant.rawValue - ] - let fallbackSound: Preferences.Sound = db[.defaultNotificationSound] - .defaulting(to: Preferences.Sound.defaultNotificationSound) - let sound: Preferences.Sound? = self.requestSound( - thread: thread, - fallbackSound: fallbackSound, - applicationState: applicationState - ) - notify( - category: .errorMessage, - title: notificationTitle, - body: notificationBody, - userInfo: userInfo, - previewType: previewType, - sound: sound, - threadVariant: thread.variant, - threadName: threadName, - applicationState: applicationState - ) + notificationCenter.add(request) + _notifications.performUpdate { $0.setting(content.identifier, request) } } // MARK: - Clearing @@ -422,117 +223,14 @@ public class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate, // MARK: - Convenience private extension NotificationPresenter { - func notify( - category: AppNotificationCategory, - title: String?, - body: String, - userInfo: [AnyHashable: Any], - previewType: Preferences.NotificationPreviewType, - sound: Preferences.Sound?, - threadVariant: SessionThread.Variant, - threadName: String, - applicationState: UIApplication.State, - replacingIdentifier: String? = nil - ) { - let threadIdentifier: String? = (userInfo[AppNotificationUserInfoKey.threadId] as? String) - let content = UNMutableNotificationContent() - content.categoryIdentifier = category.identifier - content.userInfo = userInfo - content.threadIdentifier = (threadIdentifier ?? content.threadIdentifier) - - let shouldGroupNotification: Bool = ( - threadVariant == .community && - replacingIdentifier == threadIdentifier - ) - if let sound = sound, sound != .none { - content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) - } - - let notificationIdentifier: String = (replacingIdentifier ?? UUID().uuidString) - let isReplacingNotification: Bool = (notifications[notificationIdentifier] != nil) - let shouldPresentNotification: Bool = shouldPresentNotification( - category: category, - applicationState: applicationState, - userInfo: userInfo, - using: dependencies - ) - var trigger: UNNotificationTrigger? - - if shouldPresentNotification { - if let displayableTitle = title?.filteredForDisplay { - content.title = displayableTitle - } - content.body = body.filteredForDisplay - - if shouldGroupNotification { - trigger = UNTimeIntervalNotificationTrigger( - timeInterval: Notifications.delayForGroupedNotifications, - repeats: false - ) - - let numberExistingNotifications: Int? = notifications[notificationIdentifier]? - .content - .userInfo[AppNotificationUserInfoKey.threadNotificationCounter] - .asType(Int.self) - var numberOfNotifications: Int = (numberExistingNotifications ?? 1) - - if numberExistingNotifications != nil { - numberOfNotifications += 1 // Add one for the current notification - - content.title = (previewType == .noNameNoPreview ? - content.title : - threadName - ) - content.body = "messageNewYouveGot" - .putNumber(numberOfNotifications) - .localized() - } - - content.userInfo[AppNotificationUserInfoKey.threadNotificationCounter] = numberOfNotifications - } - } - else { - // Play sound and vibrate, but without a `body` no banner will show. - Log.debug("supressing notification body") - } - - let request = UNNotificationRequest( - identifier: notificationIdentifier, - content: content, - trigger: trigger - ) - - Log.debug("presenting notification with identifier: \(notificationIdentifier)") - - if isReplacingNotification { cancelNotifications(identifiers: [notificationIdentifier]) } - - notificationCenter.add(request) - _notifications.performUpdate { $0.setting(notificationIdentifier, request) } - } - - private func requestSound( - thread: SessionThread, - fallbackSound: Preferences.Sound, - applicationState: UIApplication.State - ) -> Preferences.Sound? { - guard checkIfShouldPlaySound(applicationState: applicationState) else { return nil } - - return (thread.notificationSound ?? fallbackSound) - } - private func shouldPresentNotification( - category: AppNotificationCategory, + threadId: String, + category: NotificationCategory, applicationState: UIApplication.State, - userInfo: [AnyHashable: Any], using dependencies: Dependencies ) -> Bool { guard applicationState == .active else { return true } guard category == .incomingMessage || category == .errorMessage else { return true } - - guard let notificationThreadId = userInfo[AppNotificationUserInfoKey.threadId] as? String else { - Log.error("[UserNotificationPresenter] threadId was unexpectedly nil") - return true - } /// Check whether the current `frontMostViewController` is a `ConversationVC` for the conversation this notification /// would belong to then we don't want to show the notification, so retrieve the `frontMostViewController` (from the main @@ -545,22 +243,7 @@ private extension NotificationPresenter { else { return true } /// Show notifications for any **other** threads - return (conversationViewController.viewModel.threadData.threadId != notificationThreadId) - } - - private func checkIfShouldPlaySound(applicationState: UIApplication.State) -> Bool { - guard applicationState == .active else { return true } - guard dependencies[singleton: .storage, key: .playNotificationSoundInForeground] else { return false } - - let nowMs: UInt64 = UInt64(floor(Date().timeIntervalSince1970 * 1000)) - let recentThreshold = nowMs - UInt64(NotificationPresenter.audioNotificationsThrottleInterval * 1000) - - let recentNotifications = mostRecentNotifications.filter { $0 > recentThreshold } - - guard recentNotifications.count < NotificationPresenter.audioNotificationsThrottleCount else { return false } - - _mostRecentNotifications.performUpdate { $0.appending(nowMs) } - return true + return (conversationViewController.viewModel.threadData.threadId != threadId) } } diff --git a/Session/Notifications/Types/AppNotificationAction.swift b/Session/Notifications/Types/AppNotificationAction.swift index 5bf32db8c0..eaacf5b84a 100644 --- a/Session/Notifications/Types/AppNotificationAction.swift +++ b/Session/Notifications/Types/AppNotificationAction.swift @@ -3,6 +3,7 @@ // stringlint:disable import Foundation +import SessionMessagingKit enum AppNotificationAction: CaseIterable { case markAsRead @@ -17,3 +18,13 @@ extension AppNotificationAction { } } } + +extension NotificationCategory { + var actions: [AppNotificationAction] { + switch self { + case .incomingMessage: return [.markAsRead, .reply] + case .errorMessage: return [] + case .threadlessErrorMessage: return [] + } + } +} diff --git a/Session/Notifications/Types/AppNotificationUserInfoKey.swift b/Session/Notifications/Types/AppNotificationUserInfoKey.swift deleted file mode 100644 index 101b062e49..0000000000 --- a/Session/Notifications/Types/AppNotificationUserInfoKey.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. -// -// stringlint:disable - -import Foundation - -struct AppNotificationUserInfoKey { - static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" - static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" - static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" - static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" - static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" -} diff --git a/Session/Notifications/UserNotificationConfig.swift b/Session/Notifications/UserNotificationConfig.swift index 970e1c007a..3d36c44ee0 100644 --- a/Session/Notifications/UserNotificationConfig.swift +++ b/Session/Notifications/UserNotificationConfig.swift @@ -9,15 +9,15 @@ import SessionUtilitiesKit class UserNotificationConfig { class var allNotificationCategories: Set { - let categories = AppNotificationCategory.allCases.map { notificationCategory($0) } + let categories = NotificationCategory.allCases.map { notificationCategory($0) } return Set(categories) } - class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] { + class func notificationActions(for category: NotificationCategory) -> [UNNotificationAction] { return category.actions.map { notificationAction($0) } } - class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory { + class func notificationCategory(_ category: NotificationCategory) -> UNNotificationCategory { return UNNotificationCategory( identifier: category.identifier, actions: notificationActions(for: category), diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index a43e3e2f38..a2ff393267 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -224,7 +224,8 @@ extension Onboarding { logStartAndStopCalls: false, customAuthMethod: Authentication.standard( sessionId: userSessionId, - ed25519KeyPair: identity.ed25519KeyPair + ed25519PublicKey: identity.ed25519KeyPair.publicKey, + ed25519SecretKey: identity.ed25519KeyPair.secretKey ), using: dependencies ) @@ -235,7 +236,7 @@ extension Onboarding { .tryMap { [userSessionId, dependencies] messages, _, _, _ -> PollResult? in guard let targetMessage: ProcessedMessage = messages.last, /// Just in case there are multiple - case let .config(_, _, serverHash, serverTimestampMs, data) = targetMessage + case let .config(_, _, serverHash, serverTimestampMs, data, _) = targetMessage else { return nil } /// In order to process the config message we need to create and load a `libSession` cache, but we don't want to load this into @@ -305,7 +306,7 @@ extension Onboarding { DispatchQueue.global(qos: .userInitiated).async(using: dependencies) { [weak self, initialFlow, userSessionId, ed25519KeyPair, x25519KeyPair, useAPNS, displayName, userProfileConfigMessage, dependencies] in /// Cache the users session id (so we don't need to fetch it from the database every time) dependencies.mutate(cache: .general) { - $0.setCachedSessionId(sessionId: userSessionId) + $0.setSecretKey(ed25519SecretKey: ed25519KeyPair.secretKey) } /// If we had a proper `initialFlow` then create a new `libSession` cache for the user diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index c347d9e474..50e3bacd79 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -987,7 +987,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, _ = try SnodeReceivedMessageInfo.deleteAll(db) _ = try SessionThread.deleteAll(db) - _ = try ControlMessageProcessRecord.deleteAll(db) + _ = try MessageDeduplication.deleteAll(db) _ = try ClosedGroup.deleteAll(db) _ = try OpenGroup.deleteAll(db) _ = try Capability.deleteAll(db) @@ -1002,6 +1002,9 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, _ = try ConfigDump.deleteAll(db) } + /// Remove all dedupe record files + dependencies[singleton: .extensionHelper].deleteAllDedupeRecords() + Log.info("[DevSettings] Reloading state for \(String(describing: updatedNetwork))") /// Update to the new `ServiceNetwork` diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index dc9b721c77..a0885333ad 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -345,13 +345,11 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true), using: dependencies ), - authorName: (cellViewModel.authorId != cellViewModel.currentUserSessionId ? + authorName: (!(cellViewModel.currentUserSessionIds ?? []).contains(cellViewModel.authorId ?? "") ? cellViewModel.authorName(for: .contact) : nil ), - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.smallFontSize, textColor: textColor, @@ -385,9 +383,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC displayNameLabel?.attributedText = self?.getHighlightedSnippet( content: cellViewModel.displayName, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.mediumFontSize, textColor: textColor, @@ -406,9 +402,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC if cellViewModel.threadVariant == .legacyGroup || cellViewModel.threadVariant == .group { snippetLabel?.attributedText = self?.getHighlightedSnippet( content: (cellViewModel.threadMemberNames ?? ""), - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), searchText: searchText.lowercased(), fontSize: Values.smallFontSize, textColor: textColor, @@ -663,9 +657,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC string: MentionUtilities.highlightMentionsNoAttributes( in: previewText, threadVariant: cellViewModel.threadVariant, - currentUserSessionId: cellViewModel.currentUserSessionId, - currentUserBlinded15SessionId: cellViewModel.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: cellViewModel.currentUserBlinded25SessionId, + currentUserSessionIds: (cellViewModel.currentUserSessionIds ?? []), using: dependencies ), attributes: [ .foregroundColor: textColor ] @@ -677,9 +669,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC private func getHighlightedSnippet( content: String, authorName: String? = nil, - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, searchText: String, fontSize: CGFloat, textColor: UIColor, @@ -709,9 +699,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC let mentionReplacedContent: String = MentionUtilities.highlightMentionsNoAttributes( in: content, threadVariant: .contact, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, using: dependencies ) let result: NSMutableAttributedString = NSMutableAttributedString( diff --git a/Session/Utilities/MentionUtilities+DisplayName.swift b/Session/Utilities/MentionUtilities+DisplayName.swift new file mode 100644 index 0000000000..97efe0cd36 --- /dev/null +++ b/Session/Utilities/MentionUtilities+DisplayName.swift @@ -0,0 +1,59 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import GRDB +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit + +public extension MentionUtilities { + static func highlightMentionsNoAttributes( + in string: String, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + using dependencies: Dependencies + ) -> String { + return MentionUtilities.highlightMentionsNoAttributes( + in: string, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { sessionId in + // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) + Profile.displayNameNoFallback( + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + } + ) + } + + static func highlightMentions( + in string: String, + threadVariant: SessionThread.Variant, + currentUserSessionIds: Set, + location: MentionLocation, + textColor: UIColor, + theme: Theme, + primaryColor: Theme.PrimaryColor, + attributes: [NSAttributedString.Key: Any], + using dependencies: Dependencies + ) -> NSAttributedString { + return MentionUtilities.highlightMentions( + in: string, + currentUserSessionIds: currentUserSessionIds, + location: location, + textColor: textColor, + theme: theme, + primaryColor: primaryColor, + attributes: attributes, + displayNameRetriever: { sessionId in + // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) + Profile.displayNameNoFallback( + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + } + ) + } +} diff --git a/SessionMessagingKit/Configuration.swift b/SessionMessagingKit/Configuration.swift index 3b4086a1da..a2257c7794 100644 --- a/SessionMessagingKit/Configuration.swift +++ b/SessionMessagingKit/Configuration.swift @@ -42,7 +42,8 @@ public enum SNMessagingKit: MigratableTarget { // Just to make the external API _022_GroupsRebuildChanges.self, _023_GroupsExpiredFlag.self, _024_FixBustedInteractionVariant.self, - _025_DropLegacyClosedGroupKeyPairTable.self + _025_DropLegacyClosedGroupKeyPairTable.self, + _026_MessageDeduplicationTable.self ] ] ) diff --git a/SessionMessagingKit/Crypto/Crypto+Attachments.swift b/SessionMessagingKit/Crypto/Crypto+Attachments.swift index 254ae6916f..1595125fe1 100644 --- a/SessionMessagingKit/Crypto/Crypto+Attachments.swift +++ b/SessionMessagingKit/Crypto/Crypto+Attachments.swift @@ -15,13 +15,12 @@ public extension Crypto.Generator { private static var aesKeySize: Int { 32 } static func encryptAttachment( - plaintext: Data, - using dependencies: Dependencies + plaintext: Data ) -> Crypto.Generator<(ciphertext: Data, encryptionKey: Data, digest: Data)> { return Crypto.Generator( id: "encryptAttachment", args: [plaintext] - ) { + ) { dependencies in // Due to paddedSize, we need to divide by two. guard plaintext.count < (UInt.max / 2) else { Log.error("[Crypto] Attachment data too long to encrypt.") diff --git a/SessionMessagingKit/Crypto/Crypto+LibSession.swift b/SessionMessagingKit/Crypto/Crypto+LibSession.swift index 7ca859eb22..7de6c01c2b 100644 --- a/SessionMessagingKit/Crypto/Crypto+LibSession.swift +++ b/SessionMessagingKit/Crypto/Crypto+LibSession.swift @@ -175,7 +175,10 @@ public extension Crypto.Verification { id: "memberAuthData", args: [groupSessionId, ed25519SecretKey, memberAuthData] ) { - guard var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) else { return false } + guard + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + ed25519SecretKey.count == 64 + else { return false } var cEd25519SecretKey: [UInt8] = ed25519SecretKey var cAuthData: [UInt8] = Array(memberAuthData) diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index 569ce960e2..bea6331051 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -4,7 +4,6 @@ import Foundation import CryptoKit -import GRDB import SessionSnodeKit import SessionUtil import SessionUtilitiesKit @@ -13,33 +12,29 @@ import SessionUtilitiesKit public extension Crypto.Generator { static func ciphertextWithSessionProtocol( - _ db: Database, plaintext: Data, - destination: Message.Destination, - using dependencies: Dependencies + destination: Message.Destination ) -> Crypto.Generator { return Crypto.Generator( id: "ciphertextWithSessionProtocol", args: [plaintext, destination] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() + ) { dependencies in let destinationX25519PublicKey: Data = try { switch destination { case .contact(let publicKey): return Data(SessionId(.standard, hex: publicKey).publicKey) case .syncMessage: return Data(dependencies[cache: .general].sessionId.publicKey) - case .closedGroup(let groupPublicKey): throw MessageSenderError.deprecatedLegacyGroup + case .closedGroup: throw MessageSenderError.deprecatedLegacyGroup default: throw MessageSenderError.signingFailed } }() var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) var maybeCiphertext: UnsafeMutablePointer? = nil var ciphertextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cDestinationPubKey.count == 32, @@ -101,30 +96,54 @@ public extension Crypto.Generator { } } } + + static func ciphertextWithXChaCha20(plaintext: Data, encKey: [UInt8]) -> Crypto.Generator { + return Crypto.Generator( + id: "ciphertextWithXChaCha20", + args: [plaintext, encKey] + ) { + var cPlaintext: [UInt8] = Array(plaintext) + var cEncKey: [UInt8] = encKey + var maybeCiphertext: UnsafeMutablePointer? = nil + var ciphertextLen: Int = 0 + + guard + cEncKey.count == 32, + session_encrypt_xchacha20( + &cPlaintext, + cPlaintext.count, + &cEncKey, + &maybeCiphertext, + &ciphertextLen + ), + ciphertextLen > 0, + let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) + else { throw MessageSenderError.encryptionFailed } + + free(UnsafeMutableRawPointer(mutating: maybeCiphertext)) + + return ciphertext + } + } } // MARK: - Decryption public extension Crypto.Generator { static func plaintextWithSessionProtocol( - _ db: Database, - ciphertext: Data, - using dependencies: Dependencies + ciphertext: Data ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { return Crypto.Generator( id: "plaintextWithSessionProtocol", args: [ciphertext] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, session_decrypt_incoming( @@ -187,6 +206,8 @@ public extension Crypto.Generator { id: "plaintextWithMultiEncrypt", args: [ciphertext, senderSessionId, ed25519PrivateKey, domain] ) { + guard ed25519PrivateKey.count == 64 else { throw CryptoError.missingUserSecretKey } + var outLen: Int = 0 var cEncryptedData: [UInt8] = Array(ciphertext) var cEd25519PrivateKey: [UInt8] = ed25519PrivateKey @@ -228,6 +249,35 @@ public extension Crypto.Generator { return String(cString: cHash) } } + + static func plaintextWithXChaCha20(ciphertext: Data, encKey: [UInt8]) -> Crypto.Generator { + return Crypto.Generator( + id: "plaintextWithXChaCha20", + args: [ciphertext, encKey] + ) { + var cCiphertext: [UInt8] = Array(ciphertext) + var cEncKey: [UInt8] = encKey + var maybePlaintext: UnsafeMutablePointer? = nil + var plaintextLen: Int = 0 + + guard + cEncKey.count == 32, + session_decrypt_xchacha20( + &cCiphertext, + cCiphertext.count, + &cEncKey, + &maybePlaintext, + &plaintextLen + ), + plaintextLen > 0, + let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) + else { throw MessageReceiverError.decryptionFailed } + + free(UnsafeMutableRawPointer(mutating: maybePlaintext)) + + return plaintext + } + } } // MARK: - DisplayPicture @@ -235,10 +285,12 @@ public extension Crypto.Generator { public extension Crypto.Generator { static func encryptedDataDisplayPicture( data: Data, - key: Data, - using dependencies: Dependencies + key: Data ) -> Crypto.Generator { - return Crypto.Generator(id: "encryptedDataDisplayPicture", args: [data, key]) { + return Crypto.Generator( + id: "encryptedDataDisplayPicture", + args: [data, key] + ) { dependencies in // The key structure is: nonce || ciphertext || authTag guard key.count == DisplayPictureManager.aes256KeyByteLength, @@ -259,11 +311,15 @@ public extension Crypto.Generator { static func decryptedDataDisplayPicture( data: Data, - key: Data, - using dependencies: Dependencies + key: Data ) -> Crypto.Generator { - return Crypto.Generator(id: "decryptedDataDisplayPicture", args: [data, key]) { - guard key.count == DisplayPictureManager.aes256KeyByteLength else { throw CryptoError.failedToGenerateOutput } + return Crypto.Generator( + id: "decryptedDataDisplayPicture", + args: [data, key] + ) { dependencies in + guard key.count == DisplayPictureManager.aes256KeyByteLength else { + throw CryptoError.failedToGenerateOutput + } // The key structure is: nonce || ciphertext || authTag let cipherTextLength: Int = (data.count - (DisplayPictureManager.nonceLength + DisplayPictureManager.tagLength)) diff --git a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift index 2e3793a6fd..347e02511e 100644 --- a/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -14,7 +14,7 @@ enum _001_InitialSetupMigration: Migration { Contact.self, Profile.self, SessionThread.self, DisappearingMessagesConfiguration.self, ClosedGroup.self, OpenGroup.self, Capability.self, BlindedIdLookup.self, GroupMember.self, Interaction.self, Attachment.self, InteractionAttachment.self, Quote.self, - LinkPreview.self, ControlMessageProcessRecord.self, ThreadTypingIndicator.self + LinkPreview.self, ThreadTypingIndicator.self ] public static let fullTextSearchTokenizer: FTS5TokenizerDescriptor = { diff --git a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift index 08b2f3da10..06a833aa1f 100644 --- a/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionMessagingKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -14,6 +14,13 @@ enum _002_SetupStandardJobs: Migration { static let createdTables: [(TableRecord & FetchableRecord).Type] = [] static func migrate(_ db: Database, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + // Start by adding the jobs that don't have collections (in the jobs like these // will be added via migrations) try db.execute(sql: """ diff --git a/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift b/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift index 8253e6e134..f8bce6c4bd 100644 --- a/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift +++ b/SessionMessagingKit/Database/Migrations/_019_ScheduleAppUpdateCheckJob.swift @@ -11,6 +11,13 @@ enum _019_ScheduleAppUpdateCheckJob: Migration { static let createdTables: [(TableRecord & FetchableRecord).Type] = [] static func migrate(_ db: Database, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + try db.execute(sql: """ INSERT INTO job (variant, behaviour) VALUES (\(Job.Variant.checkForAppUpdates.rawValue), \(Job.Behaviour.recurring.rawValue)) diff --git a/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift b/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift index e054313200..2518c69554 100644 --- a/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift +++ b/SessionMessagingKit/Database/Migrations/_021_ReworkRecipientState.swift @@ -128,14 +128,21 @@ enum _021_ReworkRecipientState: Migration { /// Any interactions which didn't have a `recipientState` or a `MessageSendJob` should be considered `sent` (as /// the old UI behaviour was to render any messages without a `recipientState` as `sent`) - let interactionIdsWithMessageSendJobs: Set = try Int64.fetchSet(db, sql: """ - SELECT interactionId - FROM job - WHERE ( - variant = \(Job.Variant.messageSend.rawValue) AND - interactionId IS NOT NULL - ) - """) + var interactionIdsWithMessageSendJobs: Set = [] + + /// Only fetch from the `jobs` table if it exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + if !SNUtilitiesKit.isRunningTests || ((try? db.tableExists("job")) == true) { + interactionIdsWithMessageSendJobs = try Int64.fetchSet(db, sql: """ + SELECT interactionId + FROM job + WHERE ( + variant = \(Job.Variant.messageSend.rawValue) AND + interactionId IS NOT NULL + ) + """) + } + let interactionIdsToExclude: Set = Set(recipientStateInfo .map { info -> Int64 in info["interactionId"] }) .union(interactionIdsWithMessageSendJobs) diff --git a/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift new file mode 100644 index 0000000000..9e6e66d9af --- /dev/null +++ b/SessionMessagingKit/Database/Migrations/_026_MessageDeduplicationTable.swift @@ -0,0 +1,387 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit +import SessionSnodeKit + +/// The different platforms use different approaches for message deduplication but in the future we want to shift the database logic into +/// `libSession` so it makes sense to try to define a longer-term deduplication approach we we can use in `libSession`, additonally +/// the PN extension will need to replicate this deduplication data so having a single source-of-truth for the data will make things easier +enum _026_MessageDeduplicationTable: Migration { + static let target: TargetMigrations.Identifier = .messagingKit + static let identifier: String = "MessageDeduplicationTable" + static let minExpectedRunDuration: TimeInterval = 5 + static var createdTables: [(FetchableRecord & TableRecord).Type] = [ + MessageDeduplication.self + ] + + static func migrate(_ db: Database, using dependencies: Dependencies) throws { + typealias DedupeRecord = ( + threadId: String, + identifier: String, + timestampMs: Int64, + finalExpirationTimestampSeconds: Int64?, + shouldDeleteWhenDeletingThread: Bool + ) + + /// Pre-calculate the required timestamps + /// + /// **oldestSnodeTimestampMs:** Messages on a snode expire after ~14 days so exclude older messages + /// **oldestNotificationDedupeTimestampMs:** We probably only need to create "dedupe" records for the PN extension + /// for messages sent within the last ~60 mins (any older and the user probably wouldn't get a PN + let timestampNowInSec: Int64 = Int64(dependencies.dateNow.timeIntervalSince1970) + let oldestSnodeTimestampMs: Int64 = ((timestampNowInSec * 1000) - SnodeReceivedMessage.defaultExpirationMs) + let oldestNotificationDedupeTimestampMs: Int64 = ((timestampNowInSec - (60 * 60)) * 1000) + + try db.create(table: "messageDeduplication") { t in + t.column("threadId", .text) + .notNull() + .indexed() // Quicker querying + t.column("uniqueIdentifier", .text) + .notNull() + .indexed() // Quicker querying + t.column("expirationTimestampSeconds", .integer) + .indexed() // Quicker querying + t.column("shouldDeleteWhenDeletingThread", .boolean) + .notNull() + .defaults(to: false) + t.primaryKey(["threadId", "uniqueIdentifier"]) + } + + /// Pre-create the insertion SQL to avoid having to construct it in every iteration + let insertSQL = """ + INSERT INTO messageDeduplication (threadId, uniqueIdentifier, expirationTimestampSeconds, shouldDeleteWhenDeletingThread) + VALUES (?, ?, ?, ?) + ON CONFLICT(threadId, uniqueIdentifier) DO NOTHING + """ + let insertStatement = try db.makeStatement(sql: insertSQL) + + /// Retrieve existing de-duplication information + let threadInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + id AS threadId, + variant AS threadVariant + FROM thread + """) + let interactionInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + threadId, + variant AS interactionVariant, + timestampMs, + serverHash, + openGroupServerMessageId, + expiresInSeconds, + expiresStartedAtMs + FROM interaction + WHERE ( + timestampMs > \(oldestSnodeTimestampMs) OR NOT ( + -- Quick way to include all community messages without joining the thread table + LENGTH(threadId) = 66 AND ( + (threadId >= '03' AND threadId < '04') OR + (threadId >= '05' AND threadId < '06') OR + (threadId >= '15' AND threadId < '16') OR + (threadId >= '25' AND threadId < '26') + ) + ) + ) + """) + let controlMessageProcessRecords: [Row] = try Row.fetchAll(db, sql: """ + SELECT + threadId, + variant, + timestampMs, + serverExpirationTimestamp + FROM controlMessageProcessRecord + """) + + /// Put the known hashes into a temporary table (if we got interactions with hashes + var expirationByHash: [String: Int64] = [:] + let allHashes: Set = Set(interactionInfo.compactMap { row in row["serverHash"] }) + + if !allHashes.isEmpty { + try db.execute(sql: "CREATE TEMP TABLE tmpHashes (hash TEXT PRIMARY KEY NOT NULL)") + let insertHashSQL = "INSERT OR IGNORE INTO tmpHashes (hash) VALUES (?)" + let insertHashStatement = try db.makeStatement(sql: insertHashSQL) + try allHashes.forEach { try insertHashStatement.execute(arguments: [$0]) } + + /// Query the `snodeReceivedMessageInfo` table to extract the expiration for only the know hashes + let receivedMessageInfo: [Row] = try Row.fetchAll(db, sql: """ + SELECT + snodeReceivedMessageInfo.hash, + MIN(snodeReceivedMessageInfo.expirationDateMs) AS expirationDateMs + FROM snodeReceivedMessageInfo + JOIN tmpHashes ON tmpHashes.hash = snodeReceivedMessageInfo.hash + GROUP BY snodeReceivedMessageInfo.hash + """) + receivedMessageInfo.forEach { row in + expirationByHash[row["hash"]] = row["expirationDateMs"] + } + try db.execute(sql: "DROP TABLE tmpHashes") + } + + let threadVariants: [String: SessionThread.Variant] = threadInfo + .reduce(into: [:]) { result, row in + guard + let threadId: String = row["threadId"], + let rawThreadVariant: Int = row["threadVariant"], + let threadVariant: SessionThread.Variant = SessionThread.Variant(rawValue: rawThreadVariant) + else { return } + + result[threadId] = threadVariant + } + + /// Update the progress (from testing the above fetching took ~60% of the duration of the migration) + Storage.update(progress: 0.6, for: self, in: target, using: dependencies) + + var recordsToInsert: [DedupeRecord] = [] + var processedKeys: Set = [] + + /// Process interactions + interactionInfo.forEach { row in + guard + let threadId: String = row["threadId"], + let rawInteractionVariant: Int = row["interactionVariant"], + let threadVariant: SessionThread.Variant = threadVariants[threadId], + let interactionVariant: Interaction.Variant = Interaction.Variant(rawValue: rawInteractionVariant), + let identifier: String = { + /// Messages stored on a snode should always have a `serverHash` value (aside from old control messages + /// which may not have because they were created locally and the hash wasn't attached during creation) + if let hash: String = row["serverHash"] { return hash } + + /// Outgoing blinded message requests are sent via a community so actually have a `openGroupServerMessageId` + /// instead of a `serverHash` even though they are considered `contact` conversations so we need to handle + /// both values to ensure we don't miss the deduplication record + if let id: Int64 = row["openGroupServerMessageId"] { return "\(id)" } + + /// Some control messages (and even buggy "proper" messages) could be inserted into the database without + /// either a `serverHash` or `openGroupServerMessageId` but still create a + /// `ControlMessageProcessRecord`, for those cases we want + if let variant: Int64 = row["variant"], let timestampMs: Int64 = row["timestampMs"] { + return "\(variant)-\(timestampMs)" + } + + /// If we have none of the above values then we can't dedupe this message at all + return nil + }() + else { return } + + let expirationTimestampSeconds: Int64? = { + /// Messages in a community conversation don't expire + guard threadVariant != .community else { return nil } + + /// If we have a server expiration for the hash then we should use that value as the priority + if + let hash: String = row["serverHash"], + let expirationTimestampMs: Int64 = expirationByHash[hash] + { + return (expirationTimestampMs / 1000) + } + + /// If this is a disappearing message then fallback to using that value + if + let expiresStartedAtMs: Int64 = row["expiresStartedAtMs"], + let expiresInSeconds: Int64 = row["expiresInSeconds"] + { + return ((expiresStartedAtMs / 1000) + expiresInSeconds) + } + + /// If we got here then it means we have no way to know when the message should expire but messages stored on + /// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration + /// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs` + /// + /// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message + /// which never expires or has it's TTL extended (outside of config messages) + /// + /// If we have a `timestampMs` then base our custom expiration on that + if let timestampMs: Int64 = row["timestampMs"] { + return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + } + + /// Otherwise just use the current time if we somehow don't have a timestamp (this case shouldn't be possible) + return (timestampNowInSec + (SnodeReceivedMessage.defaultExpirationMs / 1000)) + }() + + /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server + let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds + .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + + /// If this record would have already expired then there is no need to insert a record for it + guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } + + /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the + /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch threadVariant { + case .contact: return false + case .community, .legacyGroup: return true + case .group: return (interactionVariant != .infoGroupInfoInvited) + } + }() + + /// Add the record + recordsToInsert.append(( + threadId, + identifier, + ((row["timestampMs"] as? Int64) ?? timestampNowInSec), + finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread + )) + + /// Store the legacy identifier if there would be one + guard let timestampMs: Int64 = row["timestampMs"] else { return } + + processedKeys.insert("\(threadId):\(legacyDedupeIdentifier(variant: interactionVariant, timestampMs: timestampMs))") + } + + /// Some control messages could be inserted into the database without either a `serverHash` or + /// `openGroupServerMessageId` but still create a `ControlMessageProcessRecord` in which case we still want + /// to dedupe the messages so we need to add these "legacy" deduplication records + controlMessageProcessRecords.forEach { row in + guard + let threadId: String = row["threadId"], + let rawVariant: Int = row["variant"], + let variant: ControlMessageProcessRecordVariant = ControlMessageProcessRecordVariant(rawValue: rawVariant), + let timestampMs: Int64 = row["timestampMs"] + else { return } + + /// Create a custom unique identifier for the legacy record (these will be deprecated and stop being added in a + /// subsequent release + let identifier: String = "LegacyRecord-\(rawVariant)-\(timestampMs)" + + guard !processedKeys.contains("\(threadId):\(identifier)") else { return } + + let expirationTimestampSeconds: Int64? = { + /// If we have a server expiration for the hash then we should use that value as the priority + if let serverExpirationTimestamp: TimeInterval = row["serverExpirationTimestamp"] { + return Int64(serverExpirationTimestamp) + } + + /// If we got here then it means we have no way to know when the message should expire but messages stored on + /// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration + /// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs` + /// + /// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message + /// which never expires or has it's TTL extended (outside of config messages) + /// + /// If we have a `timestampMs` then base our custom expiration on that + return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + }() + + /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server + let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds + .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + + /// If this record would have already expired then there is no need to insert a record for it + guard (finalExpiryTimestampSeconds ?? timestampNowInSec) < timestampNowInSec else { return } + + /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the + /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch variant { + case .groupUpdateInvite, .groupUpdatePromote, .groupUpdateMemberLeft, + .groupUpdateInviteResponse: + return false + default: return true + } + }() + + /// Add the record + recordsToInsert.append(( + threadId, + identifier, + timestampMs, + finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread + )) + } + + /// Insert all of the dedupe records + try recordsToInsert.forEach { record in + try insertStatement.execute(arguments: [ + record.threadId, + record.identifier, + record.finalExpirationTimestampSeconds, + record.shouldDeleteWhenDeletingThread + ]) + + /// Create dedupe records for the PN extension + if record.timestampMs > oldestNotificationDedupeTimestampMs { + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.identifier + ) + } + } + + /// Drop the old `controlMessageProcessRecord` table (since we no longer need it) + try db.execute(sql: "DROP TABLE controlMessageProcessRecord") + + Storage.update(progress: 1, for: self, in: target, using: dependencies) + } +} + +internal extension _026_MessageDeduplicationTable { + static func legacyDedupeIdentifier( + variant: Interaction.Variant, + timestampMs: Int64 + ) -> String { + let processRecordVariant: ControlMessageProcessRecordVariant = { + switch variant { + case .standardOutgoing, .standardIncoming, ._legacyStandardIncomingDeleted, + .standardIncomingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeleted, + .standardOutgoingDeletedLocally, .infoLegacyGroupCreated: + return .visibleMessageDedupe + + case .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft: return .legacyGroupControlMessage + case .infoDisappearingMessagesUpdate: return .expirationTimerUpdate + case .infoScreenshotNotification, .infoMediaSavedNotification: return .dataExtractionNotification + case .infoMessageRequestAccepted: return .messageRequestResponse + case .infoCall: return .call + case .infoGroupInfoUpdated: return .groupUpdateInfoChange + case .infoGroupInfoInvited, .infoGroupMembersUpdated: return .groupUpdateMemberChange + + case .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving: + return .groupUpdateMemberLeft + } + }() + + return "LegacyRecord-\(processRecordVariant.rawValue)-\(timestampMs)" + } +} + +internal extension _026_MessageDeduplicationTable { + enum ControlMessageProcessRecordVariant: Int { + case readReceipt = 1 + case typingIndicator = 2 + case legacyGroupControlMessage = 3 + case dataExtractionNotification = 4 + case expirationTimerUpdate = 5 + case unsendRequest = 7 + case messageRequestResponse = 8 + case call = 9 + case visibleMessageDedupe = 10 + case groupUpdateInvite = 11 + case groupUpdatePromote = 12 + case groupUpdateInfoChange = 13 + case groupUpdateMemberChange = 14 + case groupUpdateMemberLeft = 15 + case groupUpdateMemberLeftNotification = 16 + case groupUpdateInviteResponse = 17 + case groupUpdateDeleteMemberContent = 18 + } +} diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 0c65a5505d..0acc01ac1d 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -248,6 +248,37 @@ extension Attachment: CustomStringConvertible { self.contentType = contentType self.sourceFilename = sourceFilename } + + public init(id: String, proto: SNProtoAttachmentPointer, sourceFilename: String? = nil) { + self.init( + id: id, + variant: { + let voiceMessageFlag: Int32 = SNProtoAttachmentPointer.SNProtoAttachmentPointerFlags + .voiceMessage + .rawValue + + guard proto.hasFlags && ((proto.flags & UInt32(voiceMessageFlag)) > 0) else { + return .standard + } + + return .voiceMessage + }(), + contentType: ( + proto.contentType ?? + Attachment.inferContentType(from: proto.fileName) + ), + sourceFilename: sourceFilename + ) + } + } + + public var descriptionInfo: DescriptionInfo { + Attachment.DescriptionInfo( + id: id, + variant: variant, + contentType: contentType, + sourceFilename: sourceFilename + ) } public static func description(for descriptionInfo: DescriptionInfo?, count: Int?) -> String? { @@ -391,16 +422,16 @@ extension Attachment { // MARK: - Protobuf extension Attachment { - public init(proto: SNProtoAttachmentPointer) { - func inferContentType(from filename: String?) -> String { - guard - let fileName: String = filename, - let fileExtension: String = URL(string: fileName)?.pathExtension - else { return UTType.mimeTypeDefault } - - return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault) - } + public static func inferContentType(from filename: String?) -> String { + guard + let fileName: String = filename, + let fileExtension: String = URL(string: fileName)?.pathExtension + else { return UTType.mimeTypeDefault } + return (UTType.sessionMimeType(for: fileExtension) ?? UTType.mimeTypeDefault) + } + + public init(proto: SNProtoAttachmentPointer) { self.id = UUID().uuidString self.serverId = "\(proto.id)" self.variant = { @@ -415,7 +446,7 @@ extension Attachment { return .voiceMessage }() self.state = .pendingDownload - self.contentType = (proto.contentType ?? inferContentType(from: proto.fileName)) + self.contentType = (proto.contentType ?? Attachment.inferContentType(from: proto.fileName)) self.byteCount = UInt(proto.size) self.creationTimestamp = nil self.sourceFilename = proto.fileName @@ -1214,7 +1245,7 @@ extension Attachment { if destination.shouldEncrypt { guard let result: EncryptionData = dependencies[singleton: .crypto].generate( - .encryptAttachment(plaintext: rawData, using: dependencies) + .encryptAttachment(plaintext: rawData) ) else { Log.error([cat].compactMap { $0 }, "Couldn't encrypt attachment.") diff --git a/SessionMessagingKit/Database/Models/ClosedGroup.swift b/SessionMessagingKit/Database/Models/ClosedGroup.swift index 4ac7f6625a..880be73d5b 100644 --- a/SessionMessagingKit/Database/Models/ClosedGroup.swift +++ b/SessionMessagingKit/Database/Models/ClosedGroup.swift @@ -174,10 +174,6 @@ public extension ClosedGroup { group: ClosedGroup, using dependencies: Dependencies ) throws { - guard let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - throw MessageReceiverError.noUserED25519KeyPair - } - /// Update the `USER_GROUPS` config try? LibSession.update( db, @@ -226,7 +222,7 @@ public extension ClosedGroup { _ = try? cache.createAndLoadGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupIdentityPrivateKey: group.groupIdentityPrivateKey ) } @@ -263,7 +259,6 @@ public extension ClosedGroup { } // Remove the group from the database and unsubscribe from PNs - let userSessionId: SessionId = dependencies[cache: .general].sessionId let threadVariants: [ThreadIdVariant] = try { guard dataToRemove.contains(.pushNotifications) || @@ -340,15 +335,9 @@ public extension ClosedGroup { .filter(threadIds.contains(Interaction.Columns.threadId)) .deleteAll(db) - /// Delete any `ControlMessageProcessRecord` entries that we want to reprocess if the member gets + /// Delete any `MessageDeduplication` entries that we want to reprocess if the member gets /// re-invited to the group with historic access (these are repeatable records so won't cause issues if we re-run them) - try ControlMessageProcessRecord - .filter(threadIds.contains(ControlMessageProcessRecord.Columns.threadId)) - .filter( - ControlMessageProcessRecord.Variant.variantsToBeReprocessedAfterLeavingAndRejoiningConversation - .contains(ControlMessageProcessRecord.Columns.variant) - ) - .deleteAll(db) + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) /// Also want to delete the `SnodeReceivedMessageInfo` so if the member gets re-invited to the group with /// historic access they can re-download and process all of the old messages diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 8a72003b03..ef43e4a2a0 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -53,7 +53,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis // MARK: - Initialization public init( - _ db: Database? = nil, id: String, isTrusted: Bool = false, isApproved: Bool = false, @@ -84,7 +83,7 @@ public extension Contact { /// **Note:** This method intentionally does **not** save the newly created Contact, /// it will need to be explicitly saved after calling static func fetchOrCreate(_ db: Database, id: ID, using dependencies: Dependencies) -> Contact { - return ((try? fetchOne(db, id: id)) ?? Contact(db, id: id, using: dependencies)) + return ((try? fetchOne(db, id: id)) ?? Contact(id: id, using: dependencies)) } } diff --git a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift b/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift deleted file mode 100644 index 10abba5685..0000000000 --- a/SessionMessagingKit/Database/Models/ControlMessageProcessRecord.swift +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit -import SessionSnodeKit - -/// We can rely on the unique constraints within the `Interaction` table to prevent duplicate `VisibleMessage` -/// values from being processed, but some control messages don’t have an associated interaction - this table provides -/// a de-duping mechanism for those messages -/// -/// **Note:** It’s entirely possible for there to be a false-positive with this record where multiple users sent the same -/// type of control message at the same time - this is very unlikely to occur though since unique to the millisecond level -public struct ControlMessageProcessRecord: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { - public static var databaseTableName: String { "controlMessageProcessRecord" } - - /// For notifications and migrated timestamps default to '15' days (which is the timeout for messages on the - /// server at the time of writing) - public static let defaultExpirationSeconds: TimeInterval = (15 * 24 * 60 * 60) - - public typealias Columns = CodingKeys - public enum CodingKeys: String, CodingKey, ColumnExpression { - case threadId - case timestampMs - case variant - case serverExpirationTimestamp - } - - public enum Variant: Int, Codable, DatabaseValueConvertible { - @available(*, deprecated, message: "Removed along with legacy db migration") case legacyEntry = 0 - - case readReceipt = 1 - case typingIndicator = 2 - @available(*, deprecated) case legacyGroupControlMessage = 3 - case dataExtractionNotification = 4 - case expirationTimerUpdate = 5 - @available(*, deprecated) case configurationMessage = 6 - case unsendRequest = 7 - case messageRequestResponse = 8 - case call = 9 - - /// Since we retrieve messages from all snodes in a swarm there is a fun issue where a user can delete a - /// one-to-one conversation (which removes all associated interactions) and then the poller checks a - /// different service node, if a previously processed message hadn't been processed yet for that specific - /// service node it results in the conversation re-appearing - /// - /// This `Variant` allows us to create a record which survives thread deletion to prevent a duplicate - /// message from being reprocessed - case visibleMessageDedupe = 10 - - case groupUpdateInvite = 11 - case groupUpdatePromote = 12 - case groupUpdateInfoChange = 13 - case groupUpdateMemberChange = 14 - case groupUpdateMemberLeft = 15 - case groupUpdateMemberLeftNotification = 16 - case groupUpdateInviteResponse = 17 - case groupUpdateDeleteMemberContent = 18 - - internal static let variantsToBeReprocessedAfterLeavingAndRejoiningConversation: Set = [ - .dataExtractionNotification, .expirationTimerUpdate, .unsendRequest, - .messageRequestResponse, .call, .visibleMessageDedupe, .groupUpdateInfoChange, .groupUpdateMemberChange, - .groupUpdateMemberLeftNotification, .groupUpdateDeleteMemberContent - ] - } - - /// The id for the thread the control message is associated to - /// - /// **Note:** For user-specific control message (eg. `ConfigurationMessage`) this value will be the - /// users public key - public let threadId: String - - /// The type of control message - /// - /// **Note:** It would be nice to include this in the unique constraint to reduce the likelihood of false positives - /// but this can result in control messages getting re-handled because the variant is unknown in the migration - public let variant: Variant - - /// The timestamp of the control message - public let timestampMs: Int64 - - /// The timestamp for when this message will expire on the server (will be used for garbage collection) - public let serverExpirationTimestamp: TimeInterval? - - // MARK: - Initialization - - public init?( - threadId: String, - message: Message, - serverExpirationTimestamp: TimeInterval? - ) { - // Allow duplicates for UnsendRequest messages, if a user received an UnsendRequest - // as a push notification the it wouldn't include a serverHash and, as a result, - // wouldn't get deleted from the server - since the logic only runs if we find a - // matching message the safest option is to allow duplicate handling to avoid an - // edge-case where a message doesn't get deleted - if message is UnsendRequest { return nil } - - // Allow duplicates for all call messages, the double checking will be done on - // message handling to make sure the messages are for the same ongoing call - if message is CallMessage { return nil } - - // The `LibSessionMessage` doesn't have enough metadata to be able to dedupe via - // the `ControlMessageProcessRecord` so just always process it - if message is LibSessionMessage { return nil } - - /// For all other cases we want to prevent duplicate handling of the message (this can happen in a number of situations, primarily - /// with sync messages though hence why we don't include the 'serverHash' as part of this record - /// - /// **Note:** We should make sure to have a unique `variant` for any message type which could have the same timestamp - /// as another message type as otherwise they might incorrectly be deduped - self.threadId = threadId - self.variant = { - switch message { - case is ReadReceipt: return .readReceipt - case is TypingIndicator: return .typingIndicator - case is DataExtractionNotification: return .dataExtractionNotification - case is ExpirationTimerUpdate: return .expirationTimerUpdate - case is UnsendRequest: return .unsendRequest - case is MessageRequestResponse: return .messageRequestResponse - case is CallMessage: return .call - case is VisibleMessage: return .visibleMessageDedupe - - case is GroupUpdateInviteMessage: return .groupUpdateInvite - case is GroupUpdatePromoteMessage: return .groupUpdatePromote - case is GroupUpdateInfoChangeMessage: return .groupUpdateInfoChange - case is GroupUpdateMemberChangeMessage: return .groupUpdateMemberChange - case is GroupUpdateMemberLeftMessage: return .groupUpdateMemberLeft - case is GroupUpdateMemberLeftNotificationMessage: return .groupUpdateMemberLeftNotification - case is GroupUpdateInviteResponseMessage: return .groupUpdateInviteResponse - case is GroupUpdateDeleteMemberContentMessage: return .groupUpdateDeleteMemberContent - - default: preconditionFailure("[ControlMessageProcessRecord] Unsupported message type") - } - }() - self.timestampMs = Int64(message.sentTimestampMs ?? 0) // Default to `0` if not set - self.serverExpirationTimestamp = serverExpirationTimestamp - } -} - -// MARK: - Migration Extensions - -internal extension ControlMessageProcessRecord { - init?( - threadId: String, - variant: Interaction.Variant, - timestampMs: Int64, - using dependencies: Dependencies - ) { - switch variant { - case .standardOutgoing, .standardIncoming, ._legacyStandardIncomingDeleted, - .standardIncomingDeleted, .standardIncomingDeletedLocally, .standardOutgoingDeleted, - .standardOutgoingDeletedLocally, .infoLegacyGroupCreated, - .infoLegacyGroupUpdated, .infoLegacyGroupCurrentUserLeft: - return nil - - case .infoDisappearingMessagesUpdate: self.variant = .expirationTimerUpdate - case .infoScreenshotNotification, .infoMediaSavedNotification: self.variant = .dataExtractionNotification - case .infoMessageRequestAccepted: self.variant = .messageRequestResponse - case .infoCall: self.variant = .call - case .infoGroupInfoUpdated: self.variant = .groupUpdateInfoChange - case .infoGroupInfoInvited, .infoGroupMembersUpdated: self.variant = .groupUpdateMemberChange - - case .infoGroupCurrentUserLeaving, .infoGroupCurrentUserErrorLeaving: - self.variant = .groupUpdateMemberLeft - } - - self.threadId = threadId - self.timestampMs = timestampMs - self.serverExpirationTimestamp = ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - } - - /// This method should only be called from either the `generateLegacyProcessRecords` method above or - /// within the 'insert' method to maintain the unique constraint - fileprivate init( - threadId: String, - variant: Variant, - timestampMs: Int64, - serverExpirationTimestamp: TimeInterval - ) { - self.threadId = threadId - self.variant = variant - self.timestampMs = timestampMs - self.serverExpirationTimestamp = serverExpirationTimestamp - } -} diff --git a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift index 02076eb611..99ac49a08d 100644 --- a/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift +++ b/SessionMessagingKit/Database/Models/DisappearingMessageConfiguration.swift @@ -319,8 +319,7 @@ public extension DisappearingMessagesConfiguration { threadId: threadId, threadVariant: threadVariant, timestampMs: timestampMs, - userSessionId: userSessionId, - openGroup: nil + openGroupUrlInfo: nil ) } ) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 83ad8df770..f8956e4c3a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -914,23 +914,28 @@ public extension Interaction { quoteAuthorId: String? = nil, using dependencies: Dependencies ) -> Bool { - var publicKeysToCheck: [String] = [ + var publicKeysToCheck: Set = [ dependencies[cache: .general].sessionId.hexString ] // If the thread is an open group then add the blinded id as a key to check if let openGroup: OpenGroup = try? OpenGroup.fetchOne(db, id: threadId) { if - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEd25519KeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: openGroup.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let blinded25KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded25KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEd25519KeyPair.secretKey) + .blinded25KeyPair( + serverPublicKey: openGroup.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ) { - publicKeysToCheck.append(SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString) - publicKeysToCheck.append(SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString) + publicKeysToCheck.insert(SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString) + publicKeysToCheck.insert(SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString) } } @@ -943,7 +948,7 @@ public extension Interaction { // stringlint:ignore_contents static func isUserMentioned( - publicKeysToCheck: [String], + publicKeysToCheck: Set, body: String?, quoteAuthorId: String? = nil ) -> Bool { @@ -1383,25 +1388,33 @@ public extension Interaction { let quote: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() let interactionAttachment: TypedTableAlias = TypedTableAlias() - var blinded15SessionIdHexString: String = "" - var blinded25SessionIdHexString: String = "" + let userSessionId: SessionId = dependencies[cache: .general].sessionId + var userSessionIds: Set = [userSessionId.hexString] /// If it's a `community` conversation then we need to get the blinded ids - if threadVariant == .community { - blinded15SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded15, - using: dependencies - )?.hexString).defaulting(to: "") - blinded25SessionIdHexString = (SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: .blinded25, - using: dependencies - )?.hexString).defaulting(to: "") + if + threadVariant == .community, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + { + userSessionIds = userSessionIds.inserting( + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString + ) + userSessionIds = userSessionIds.inserting( + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + )?.hexString + ) } /// Construct a request which gets the `quote.attachmentId` for any `Quote` entries related @@ -1414,11 +1427,8 @@ public extension Interaction { \(interaction[.authorId]) = \(quote[.authorId]) OR ( -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case - \(interaction[.authorId]) = \(dependencies[cache: .general].sessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionIdHexString) OR - \(quote[.authorId]) = \(blinded25SessionIdHexString) - ) + \(interaction[.authorId]) = \(userSessionId.hexString) AND + \(quote[.authorId]) IN \(userSessionIds) ) ) ) diff --git a/SessionMessagingKit/Database/Models/MessageDeduplication.swift b/SessionMessagingKit/Database/Models/MessageDeduplication.swift new file mode 100644 index 0000000000..f3ae17b44b --- /dev/null +++ b/SessionMessagingKit/Database/Models/MessageDeduplication.swift @@ -0,0 +1,373 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit +import SessionSnodeKit + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("MessageDeduplication", defaultLevel: .info) +} + +// MARK: - MessageDeduplication + +public struct MessageDeduplication: Codable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { + public static var databaseTableName: String { "messageDeduplication" } + + public typealias Columns = CodingKeys + public enum CodingKeys: String, CodingKey, ColumnExpression { + case threadId + case uniqueIdentifier + case expirationTimestampSeconds + case shouldDeleteWhenDeletingThread + } + + public let threadId: String + public let uniqueIdentifier: String + public let expirationTimestampSeconds: Int64? + public let shouldDeleteWhenDeletingThread: Bool +} + +// MARK: - Convenience + +public extension MessageDeduplication { + static func insert( + _ db: Database, + threadId: String, + threadVariant: SessionThread.Variant?, + uniqueIdentifier: String?, + legacyIdentifier: String? = nil, + message: Message?, + serverExpirationTimestamp: Int64?, + using dependencies: Dependencies + ) throws { + /// If we don't have a `uniqueIdentifier` then we can't dedupe the message + guard let uniqueIdentifier: String = uniqueIdentifier else { return } + + /// Ensure this isn't a duplicate message received as a PN first + try ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + legacyIdentifier: legacyIdentifier, + using: dependencies + ) + + /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server + let finalExpiryTimestampSeconds: Int64? = serverExpirationTimestamp + .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + + /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the + /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch (threadVariant, message.map { Message.Variant(from: $0) }) { + case (.contact, _): return false + case (.community, _), (.legacyGroup, _): return true + case (.group, .groupUpdateInvite), (.group, .groupUpdatePromote), + (.group, .groupUpdateMemberLeft), (.group, .groupUpdateInviteResponse): + return false + case (.group, _): return true + case (.none, .none), (.none, _): return false + } + }() + + /// Insert the `MessageDeduplication` record + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + + /// Create the replicated file in the 'AppGroup' so that the PN extension is able to dedupe messages + try createDedupeFile( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier, + legacyIdentifier: legacyIdentifier, + using: dependencies + ) + + /// Create a legacy dedupe record + try createLegacyDeduplicationRecord( + db, + threadId: threadId, + legacyIdentifier: legacyIdentifier, + legacyVariant: getLegacyVariant(for: message.map { Message.Variant(from: $0) }), + timestampMs: message?.sentTimestampMs.map { Int64($0) }, + serverExpirationTimestamp: serverExpirationTimestamp, + using: dependencies + ) + } + + static func deleteIfNeeded( + _ db: Database, + threadIds: [String], + using dependencies: Dependencies + ) throws { + /// First update the rows to be considered expired (so they are garbage collected in case the file deletion fails for some reason) + try MessageDeduplication + .filter(threadIds.contains(MessageDeduplication.Columns.threadId)) + .filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true) + .updateAll( + db, + MessageDeduplication.Columns.expirationTimestampSeconds.set(to: 0) + ) + + /// Then fetch the records and try to individually delete each one + let records: [MessageDeduplication] = try MessageDeduplication + .filter(threadIds.contains(MessageDeduplication.Columns.threadId)) + .filter(MessageDeduplication.Columns.shouldDeleteWhenDeletingThread == true) + .fetchAll(db) + records.forEach { record in + do { + try dependencies[singleton: .extensionHelper].removeDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.uniqueIdentifier + ) + try record.delete(db) + } + catch { Log.warn(.cat, "Failed to delete dedupe record (will rely on garbage collection).") } + } + } + + static func createDedupeFile( + threadId: String, + uniqueIdentifier: String, + legacyIdentifier: String? = nil, + using dependencies: Dependencies + ) throws { + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier + ) + + /// Also create a dedupe file for the legacy identifier if provided + guard let legacyIdentifier: String = legacyIdentifier else { return } + + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: legacyIdentifier + ) + } + + static func ensureMessageIsNotADuplicate( + _ processedMessage: ProcessedMessage, + using dependencies: Dependencies + ) throws { + typealias Variant = _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant + try ensureMessageIsNotADuplicate( + threadId: processedMessage.threadId, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + using: dependencies + ) + } + + static func ensureMessageIsNotADuplicate( + threadId: String, + uniqueIdentifier: String, + legacyIdentifier: String? = nil, + using dependencies: Dependencies + ) throws { + if dependencies[singleton: .extensionHelper].dedupeRecordExists( + threadId: threadId, + uniqueIdentifier: uniqueIdentifier + ) { + throw MessageReceiverError.duplicateMessage + } + + /// Also check for a dedupe file using the legacy identifier + guard let legacyIdentifier: String = legacyIdentifier else { return } + + if dependencies[singleton: .extensionHelper].dedupeRecordExists( + threadId: threadId, + uniqueIdentifier: legacyIdentifier + ) { + throw MessageReceiverError.duplicateMessage + } + } + + static func ensureCallMessageIsNotADuplicate ( + threadId: String, + callMessage: CallMessage?, + using dependencies: Dependencies + ) throws { + guard let callMessage: CallMessage = callMessage else { return } + + do { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: threadId, + uniqueIdentifier: callMessage.uuid, + using: dependencies + ) + } + catch { throw MessageReceiverError.duplicatedCall } + } +} + +// MARK: - ProcessedMessage Convenience + +public extension MessageDeduplication { + static func insert( + _ db: Database, + processedMessage: ProcessedMessage, + using dependencies: Dependencies + ) throws { + typealias StandardInfo = ( + threadVariant: SessionThread.Variant, + message: Message, + serverExpirationTimestamp: Int64? + ) + + let standardInfo: StandardInfo? = { + switch processedMessage { + case .config, .invalid: return nil + case .standard(_, let threadVariant, _, let messageInfo, _): + return ( + threadVariant, + messageInfo.message, + messageInfo.serverExpirationTimestamp.map { Int64($0) } + ) + } + }() + + try insert( + db, + threadId: processedMessage.threadId, + threadVariant: standardInfo?.threadVariant, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + message: standardInfo?.message, + serverExpirationTimestamp: standardInfo?.serverExpirationTimestamp, + using: dependencies + ) + } + + static func createDedupeFile( + _ processedMessage: ProcessedMessage, + using dependencies: Dependencies + ) throws { + try createDedupeFile( + threadId: processedMessage.threadId, + uniqueIdentifier: processedMessage.uniqueIdentifier, + legacyIdentifier: getLegacyIdentifier(for: processedMessage), + using: dependencies + ) + } +} + +// MARK: - Legacy Dedupe Records + +private extension MessageDeduplication { + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + private static func createLegacyDeduplicationRecord( + _ db: Database, + threadId: String, + legacyIdentifier: String?, + legacyVariant: _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant?, + timestampMs: Int64?, + serverExpirationTimestamp: Int64?, + using dependencies: Dependencies + ) throws { + typealias Variant = _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant + guard + let legacyIdentifier: String = legacyIdentifier, + let legacyVariant: Variant = legacyVariant, + let timestampMs: Int64 = timestampMs + else { return } + + let expirationTimestampSeconds: Int64? = { + /// If we have a server expiration for the hash then we should use that value as the priority + if let serverExpirationTimestamp: Int64 = serverExpirationTimestamp { + return serverExpirationTimestamp + } + + /// If we got here then it means we have no way to know when the message should expire but messages stored on + /// a snode as well as outgoing blinded message reuqests stored on a SOGS both have a similar default expiration + /// so create one manually by using `SnodeReceivedMessage.defaultExpirationMs` + /// + /// For a `contact` conversation at the time of writing this migration there _shouldn't_ be any type of message + /// which never expires or has it's TTL extended (outside of config messages) + /// + /// If we have a `timestampMs` then base our custom expiration on that + return ((timestampMs + SnodeReceivedMessage.defaultExpirationMs) / 1000) + }() + + /// Add `(SnodeReceivedMessage.serverClockToleranceMs * 2)` to `expirationTimestampSeconds` + /// in order to try to ensure that our deduplication record outlasts the message lifetime on the storage server + let finalExpiryTimestampSeconds: Int64? = expirationTimestampSeconds + .map { $0 + (SnodeReceivedMessage.serverClockToleranceMs * 2) } + + /// When we delete a `contact` conversation we want to keep the dedupe records around because, if we don't, the + /// conversation will just reappear (this isn't an issue for `legacyGroup` conversations because they no longer poll) + /// + /// For `community` conversations we only poll while the conversation exists and have a `seqNo` to poll from in order + /// to prevent retrieving old messages + /// + /// Updated `group` conversations are a bit special because we want to delete _most_ records, but there are a few that + /// can cause issues if we process them again so we hold on to those just in case + let shouldDeleteWhenDeletingThread: Bool = { + switch legacyVariant { + case .groupUpdateInvite, .groupUpdatePromote, .groupUpdateMemberLeft, + .groupUpdateInviteResponse: + return false + default: return true + } + }() + + /// Add the record + _ = try MessageDeduplication( + threadId: threadId, + uniqueIdentifier: legacyIdentifier, + expirationTimestampSeconds: finalExpiryTimestampSeconds, + shouldDeleteWhenDeletingThread: shouldDeleteWhenDeletingThread + ).insert(db) + } + + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + static func getLegacyVariant(for variant: Message.Variant?) -> _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant? { + guard let variant: Message.Variant = variant else { return nil } + + switch variant { + case .visibleMessage: return .visibleMessageDedupe + case .readReceipt: return .readReceipt + case .typingIndicator: return .typingIndicator + case .unsendRequest: return .unsendRequest + case .dataExtractionNotification: return .dataExtractionNotification + case .expirationTimerUpdate: return .expirationTimerUpdate + case .messageRequestResponse: return .messageRequestResponse + case .callMessage: return .call + case .groupUpdateInvite, .groupUpdateMemberChange, .groupUpdatePromote: + return .groupUpdateMemberChange + case .groupUpdateInfoChange: return .groupUpdateInfoChange + case .groupUpdateMemberLeft: return .groupUpdateMemberLeft + case .groupUpdateMemberLeftNotification: return .groupUpdateMemberLeftNotification + case .groupUpdateInviteResponse: return .groupUpdateInviteResponse + case .groupUpdateDeleteMemberContent: return .groupUpdateDeleteMemberContent + + case .libSessionMessage: return nil + } + } + + @available(*, deprecated, message: "⚠️ Remove this code once once enough time has passed since it's release (at least 1 month)") + static func getLegacyIdentifier(for processedMessage: ProcessedMessage) -> String? { + switch processedMessage { + case .config, .invalid: return nil + case .standard(_, _, _, let messageInfo, _): + guard + let timestampMs: UInt64 = messageInfo.message.sentTimestampMs, + let variant: _026_MessageDeduplicationTable.ControlMessageProcessRecordVariant = getLegacyVariant(for: Message.Variant(from: messageInfo.message)) + else { return nil } + + return "LegacyRecord-\(variant.rawValue)-\(timestampMs)" // stringlint:ignore + } + } +} diff --git a/SessionMessagingKit/Database/Models/Profile.swift b/SessionMessagingKit/Database/Models/Profile.swift index 8a66d774c1..014c118ea4 100644 --- a/SessionMessagingKit/Database/Models/Profile.swift +++ b/SessionMessagingKit/Database/Models/Profile.swift @@ -369,12 +369,13 @@ public extension Profile { /// The name to display in the UI for a given thread variant func displayName( for threadVariant: SessionThread.Variant = .contact, + messageProfile: VisibleMessage.VMProfile? = nil, ignoringNickname: Bool = false ) -> String { return Profile.displayName( for: threadVariant, id: id, - name: name, + name: (messageProfile?.displayName?.nullIfEmpty ?? name), nickname: (ignoringNickname ? nil : nickname), suppressId: false ) diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 9e0e19ebf2..ac4daf572f 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -223,10 +223,15 @@ public extension SessionThread { switch try? fetchOne(db, id: id) { case .some(let existingThread): result = existingThread case .none: - let targetPriority: Int32 = dependencies - .mutate(cache: .libSession) { $0.pinnedPriority(db, threadId: id, threadVariant: variant) } - .defaulting(to: LibSession.defaultNewThreadPriority) - + let targetPriority: Int32 = dependencies.mutate(cache: .libSession) { cache in + cache.pinnedPriority( + threadId: id, + threadVariant: variant, + openGroupUrlInfo: (variant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id) + ) + ) + } result = try SessionThread( id: id, variant: variant, @@ -289,9 +294,15 @@ public extension SessionThread { /// should both be sourced from `libSession` switch (values.pinnedPriority, values.shouldBeVisible) { case (.useLibSession, .useLibSession): - let targetPriority: Int32 = dependencies - .mutate(cache: .libSession) { $0.pinnedPriority(db, threadId: id, threadVariant: variant) } - .defaulting(to: LibSession.defaultNewThreadPriority) + let targetPriority: Int32 = dependencies.mutate(cache: .libSession) { cache in + cache.pinnedPriority( + threadId: id, + threadVariant: variant, + openGroupUrlInfo: (variant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: id) + ) + ) + } let libSessionShouldBeVisible: Bool = LibSession.shouldBeVisible(priority: targetPriority) if targetPriority != result.pinnedPriority { @@ -506,6 +517,9 @@ public extension SessionThread { SessionThread.Columns.shouldBeVisible.set(to: false), using: dependencies ) + + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) case .deleteContactConversationAndMarkHidden: _ = try SessionThread @@ -530,6 +544,9 @@ public extension SessionThread { ) } + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) + // Update any other threads to be hidden try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies) @@ -550,6 +567,9 @@ public extension SessionThread { .filter(ids: remainingThreadIds) .deleteAll(db) + // Remove desired deduplication records + try MessageDeduplication.deleteIfNeeded(db, threadIds: threadIds, using: dependencies) + case .leaveGroupAsync: try threadIds.forEach { threadId in try MessageSender.leave(db, threadId: threadId, threadVariant: threadVariant, using: dependencies) @@ -660,66 +680,13 @@ public extension SessionThread { ) } - func shouldShowNotification( - _ db: Database, - for interaction: Interaction, - isMessageRequest: Bool, - using dependencies: Dependencies - ) -> Bool { - // Ensure that the thread isn't muted and either the thread isn't only notifying for mentions - // or the user was actually mentioned - guard - Date().timeIntervalSince1970 > (self.mutedUntilTimestamp ?? 0) && - ( - self.variant == .contact || - self.variant == .group || - !self.onlyNotifyForMentions || - interaction.hasMention - ) - else { return false } - - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - // No need to notify the user for self-send messages - guard interaction.authorId != userSessionId.hexString else { return false } - - // If the thread is a message request then we only want to notify for the first message - if (self.variant == .contact || self.variant == .group) && isMessageRequest { - let numInteractions: Int = { - switch interaction.serverHash { - case .some(let serverHash): - return (try? self.interactions - .filter(Interaction.Columns.serverHash != serverHash) - .fetchCount(db)) - .defaulting(to: 0) - - case .none: - return (try? self.interactions - .filter(Interaction.Columns.timestampMs != interaction.timestampMs) - .fetchCount(db)) - .defaulting(to: 0) - } - }() - - // We only want to show a notification for the first interaction in the thread - guard numInteractions == 0 else { return false } - - // Need to re-show the message requests section if it had been hidden - if db[.hasHiddenMessageRequests] { - db[.hasHiddenMessageRequests] = false - } - } - - return true - } - static func displayName( threadId: String, variant: Variant, - closedGroupName: String? = nil, - openGroupName: String? = nil, - isNoteToSelf: Bool = false, - profile: Profile? = nil + closedGroupName: String?, + openGroupName: String?, + isNoteToSelf: Bool, + profile: Profile? ) -> String { switch variant { case .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized()) @@ -735,57 +702,30 @@ public extension SessionThread { } static func getCurrentUserBlindedSessionId( - _ db: Database? = nil, threadId: String, threadVariant: Variant, blindingPrefix: SessionId.Prefix, + openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo?, using dependencies: Dependencies ) -> SessionId? { - guard threadVariant == .community else { return nil } - guard let db: Database = db else { - return dependencies[singleton: .storage].read { db in - getCurrentUserBlindedSessionId( - db, - threadId: threadId, - threadVariant: threadVariant, - blindingPrefix: blindingPrefix, - using: dependencies - ) - } - } - - // Retrieve the relevant open group info - struct OpenGroupInfo: Decodable, FetchableRecord { - let publicKey: String - let server: String - } - guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let openGroupInfo: OpenGroupInfo = try? OpenGroup - .filter(id: threadId) - .select(.publicKey, .server) - .asRequest(of: OpenGroupInfo.self) - .fetchOne(db) + threadVariant == .community, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = openGroupCapabilityInfo else { return nil } // Check the capabilities to ensure the SOGS is blinded (or whether we have no capabilities) - let capabilities: Set = (try? Capability - .select(.variant) - .filter(Capability.Columns.openGroupServer == openGroupInfo.server.lowercased()) - .asRequest(of: Capability.Variant.self) - .fetchSet(db)) - .defaulting(to: []) - - guard capabilities.isEmpty || capabilities.contains(.blind) else { return nil } + guard + openGroupCapabilityInfo.capabilities.isEmpty || + openGroupCapabilityInfo.capabilities.contains(.blind) + else { return nil } switch blindingPrefix { case .blinded15: return dependencies[singleton: .crypto] .generate( .blinded15KeyPair( - serverPublicKey: openGroupInfo.publicKey, - ed25519SecretKey: userEdKeyPair.secretKey + serverPublicKey: openGroupCapabilityInfo.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) .map { SessionId(.blinded15, publicKey: $0.publicKey) } @@ -794,8 +734,8 @@ public extension SessionThread { return dependencies[singleton: .crypto] .generate( .blinded25KeyPair( - serverPublicKey: openGroupInfo.publicKey, - ed25519SecretKey: userEdKeyPair.secretKey + serverPublicKey: openGroupCapabilityInfo.publicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey ) ) .map { SessionId(.blinded25, publicKey: $0.publicKey) } diff --git a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift index 96be65f0f4..aba703204e 100644 --- a/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift +++ b/SessionMessagingKit/Jobs/CheckForAppUpdatesJob.swift @@ -47,16 +47,10 @@ public enum CheckForAppUpdatesJob: JobExecutor { return deferred(updatedJob) } - dependencies[singleton: .storage] - .readPublisher { db -> [UInt8]? in Identity.fetchUserEd25519KeyPair(db)?.secretKey } + dependencies[singleton: .network] + .checkClientVersion(ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey) .subscribe(on: scheduler, using: dependencies) .receive(on: scheduler, using: dependencies) - .tryFlatMap { maybeEd25519SecretKey -> AnyPublisher<(ResponseInfoType, AppVersionResponse), Error> in - guard let ed25519SecretKey: [UInt8] = maybeEd25519SecretKey else { throw StorageError.objectNotFound } - - return dependencies[singleton: .network] - .checkClientVersion(ed25519SecretKey: ed25519SecretKey) - } .sinkUntilComplete( receiveCompletion: { _ in var updatedJob: Job = job.with( diff --git a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift index db74dfa733..5720b9acfb 100644 --- a/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/ConfigMessageReceiveJob.swift @@ -116,8 +116,8 @@ extension ConfigMessageReceiveJob { self.messages = messages .compactMap { processedMessage -> MessageInfo? in switch processedMessage { - case .standard: return nil - case .config(_, let namespace, let serverHash, let serverTimestampMs, let data): + case .standard, .invalid: return nil + case .config(_, let namespace, let serverHash, let serverTimestampMs, let data, _): return MessageInfo( namespace: namespace, serverHash: serverHash, diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index 6cb435ae0a..5beb87209c 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -67,10 +67,8 @@ public enum ConfigurationSyncJob: JobExecutor { // fresh install due to the migrations getting run) guard let swarmPublicKey: String = job.threadId, - let pendingChanges: LibSession.PendingChanges = dependencies[singleton: .storage].read({ db in - try dependencies.mutate(cache: .libSession) { - try $0.pendingChanges(db, swarmPublicKey: swarmPublicKey) - } + let pendingChanges: LibSession.PendingChanges = try? dependencies.mutate(cache: .libSession, { + try $0.pendingChanges(swarmPublicKey: swarmPublicKey) }) else { Log.info(.cat, "For \(job.threadId ?? "UnknownId") failed due to invalid data") diff --git a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift index ad4f5df8f8..838abe41ba 100644 --- a/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift +++ b/SessionMessagingKit/Jobs/DisappearingMessagesJob.swift @@ -27,7 +27,7 @@ public enum DisappearingMessagesJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } // The 'backgroundTask' gets captured and cleared within the 'completion' block let timestampNowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() @@ -60,7 +60,7 @@ public enum DisappearingMessagesJob: JobExecutor { public extension DisappearingMessagesJob { static func cleanExpiredMessagesOnLaunch(using dependencies: Dependencies) { - guard Identity.userExists(using: dependencies) else { return } + guard dependencies[cache: .general].userExists else { return } let timestampNowMs: Double = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() var numDeleted: Int = -1 diff --git a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift index b697dbc3cd..333a54129b 100644 --- a/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift +++ b/SessionMessagingKit/Jobs/DisplayPictureDownloadJob.swift @@ -93,7 +93,7 @@ public enum DisplayPictureDownloadJob: JobExecutor { case .community: return data // Community data is unencrypted case .profile(_, _, let encryptionKey), .group(_, _, let encryptionKey): return dependencies[singleton: .crypto].generate( - .decryptedDataDisplayPicture(data: data, key: encryptionKey, using: dependencies) + .decryptedDataDisplayPicture(data: data, key: encryptionKey) ) } }() diff --git a/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift b/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift index de52e91663..12b62b5477 100644 --- a/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift +++ b/SessionMessagingKit/Jobs/FailedAttachmentDownloadsJob.swift @@ -26,7 +26,7 @@ public enum FailedAttachmentDownloadsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } var changeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift index fa4206cb26..8b66f2ed0e 100644 --- a/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift +++ b/SessionMessagingKit/Jobs/FailedGroupInvitesAndPromotionsJob.swift @@ -26,7 +26,7 @@ public enum FailedGroupInvitesAndPromotionsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } guard !dependencies[cache: .libSession].isEmpty else { return failure(job, JobRunnerError.missingRequiredDetails, false) } diff --git a/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift b/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift index 999d891177..6ae0a025bc 100644 --- a/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift +++ b/SessionMessagingKit/Jobs/FailedMessageSendsJob.swift @@ -26,7 +26,7 @@ public enum FailedMessageSendsJob: JobExecutor { deferred: @escaping (Job) -> Void, using dependencies: Dependencies ) { - guard Identity.userExists(using: dependencies) else { return success(job, false) } + guard dependencies[cache: .general].userExists else { return success(job, false) } var changeCount: Int = -1 var attachmentChangeCount: Int = -1 diff --git a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift index 99744d96e0..5b1ec4110d 100644 --- a/SessionMessagingKit/Jobs/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/GarbageCollectionJob.swift @@ -29,6 +29,7 @@ public enum GarbageCollectionJob: JobExecutor { private struct FileInfo { let attachmentLocalRelativePaths: Set let displayPictureFilenames: Set + let messageDedupeRecords: [MessageDeduplication] } public static func run( @@ -72,7 +73,7 @@ public enum GarbageCollectionJob: JobExecutor { }() dependencies[singleton: .storage].writeAsync( - updates: { db in + updates: { db -> FileInfo in let userSessionId: SessionId = dependencies[cache: .general].sessionId /// Remove any typing indicators @@ -81,13 +82,6 @@ public enum GarbageCollectionJob: JobExecutor { .deleteAll(db) } - /// Remove any expired controlMessageProcessRecords - if finalTypesToCollect.contains(.expiredControlMessageProcessRecords) { - _ = try ControlMessageProcessRecord - .filter(ControlMessageProcessRecord.Columns.serverExpirationTimestamp <= timestampNow) - .deleteAll(db) - } - /// Remove any old open group messages - open group messages which are older than six months if finalTypesToCollect.contains(.oldOpenGroupMessages) && db[.trimOpenGroupMessagesOlderThanSixMonths] { let interaction: TypedTableAlias = TypedTableAlias() @@ -358,72 +352,74 @@ public enum GarbageCollectionJob: JobExecutor { .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (timestampNow * 1000)) .deleteAll(db) } + + /// Retrieve any files which need to be deleted + var attachmentLocalRelativePaths: Set = [] + var displayPictureFilenames: Set = [] + var messageDedupeRecords: [MessageDeduplication] = [] + + /// Orphaned attachment files - attachment files which don't have an associated record in the database + if finalTypesToCollect.contains(.orphanedAttachmentFiles) { + /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage + /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow + /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) + /// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed + attachmentLocalRelativePaths = try Attachment + .select(.localRelativeFilePath) + .filter(Attachment.Columns.localRelativeFilePath != nil) + .asRequest(of: String.self) + .fetchSet(db) + } + + /// Orphaned display picture files - profile avatar files which don't have an associated record in the database + if finalTypesToCollect.contains(.orphanedDisplayPictures) { + displayPictureFilenames.insert( + contentsOf: try Profile + .select(.profilePictureFileName) + .filter(Profile.Columns.profilePictureFileName != nil) + .asRequest(of: String.self) + .fetchSet(db) + ) + displayPictureFilenames.insert( + contentsOf: try ClosedGroup + .select(.displayPictureFilename) + .filter(ClosedGroup.Columns.displayPictureFilename != nil) + .asRequest(of: String.self) + .fetchSet(db) + ) + displayPictureFilenames.insert( + contentsOf: try OpenGroup + .select(.displayPictureFilename) + .filter(OpenGroup.Columns.displayPictureFilename != nil) + .asRequest(of: String.self) + .fetchSet(db) + ) + } + + if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + messageDedupeRecords = try MessageDeduplication + .filter(MessageDeduplication.Columns.expirationTimestampSeconds < timestampNow) + .fetchAll(db) + } + + return FileInfo( + attachmentLocalRelativePaths: attachmentLocalRelativePaths, + displayPictureFilenames: displayPictureFilenames, + messageDedupeRecords: messageDedupeRecords + ) }, - completion: { _ in - // Dispatch async so we can swap from the write queue to a read one (we are done - // writing) + completion: { result in + guard case .success(let fileInfo) = result else { + return failure(job, StorageError.generic, false) + } + + /// Dispatch async so we don't block the database threads while doing File I/O scheduler.schedule { - // Retrieve a list of all valid attachmnet and avatar file paths - let maybeFileInfo: FileInfo? = dependencies[singleton: .storage].read { db -> FileInfo in - var attachmentLocalRelativePaths: Set = [] - var displayPictureFilenames: Set = [] - - /// Orphaned attachment files - attachment files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedAttachmentFiles) { - /// **Note:** Thumbnails are stored in the `NSCachesDirectory` directory which should be automatically manage - /// it's own garbage collection so we can just ignore it according to the various comments in the following stack overflow - /// post, the directory will be cleared during app updates as well as if the system is running low on memory (if the app isn't running) - /// https://stackoverflow.com/questions/6879860/when-are-files-from-nscachesdirectory-removed - attachmentLocalRelativePaths = try Attachment - .select(.localRelativeFilePath) - .filter(Attachment.Columns.localRelativeFilePath != nil) - .asRequest(of: String.self) - .fetchSet(db) - } - - /// Orphaned display picture files - profile avatar files which don't have an associated record in the database - if finalTypesToCollect.contains(.orphanedDisplayPictures) { - displayPictureFilenames.insert( - contentsOf: try Profile - .select(.profilePictureFileName) - .filter(Profile.Columns.profilePictureFileName != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - displayPictureFilenames.insert( - contentsOf: try ClosedGroup - .select(.displayPictureFilename) - .filter(ClosedGroup.Columns.displayPictureFilename != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - displayPictureFilenames.insert( - contentsOf: try OpenGroup - .select(.displayPictureFilename) - .filter(OpenGroup.Columns.displayPictureFilename != nil) - .asRequest(of: String.self) - .fetchSet(db) - ) - } - - return FileInfo( - attachmentLocalRelativePaths: attachmentLocalRelativePaths, - displayPictureFilenames: displayPictureFilenames - ) - } - - // If we couldn't get the file lists then fail (invalid state and don't want to delete all attachment/profile files) - guard let fileInfo: FileInfo = maybeFileInfo else { - failure(job, StorageError.generic, false) - return - } - var deletionErrors: [Error] = [] - // Orphaned attachment files (actual deletion) + /// Orphaned attachment files (actual deletion) if finalTypesToCollect.contains(.orphanedAttachmentFiles) { - // Note: Looks like in order to recursively look through files we need to use the - // enumerator method + /// **Note:** Looks like in order to recursively look through files we need to use the enumerator method let fileEnumerator = dependencies[singleton: .fileManager].enumerator( at: URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)), includingPropertiesForKeys: nil, @@ -436,11 +432,10 @@ public enum GarbageCollectionJob: JobExecutor { .defaulting(to: []) .asSet() - // Note: Directories will have their own entries in the list, if there is a folder with content - // the file will include the directory in it's path with a forward slash so we can use this to - // distinguish empty directories from ones with content so we don't unintentionally delete a - // directory which contains content to keep as well as delete (directories which end up empty after - // this clean up will be removed during the next run) + /// **Note:** Directories will have their own entries in the list, if there is a folder with content the file will + /// include the directory in it's path with a forward slash so we can use this to distinguish empty directories + /// from ones with content so we don't unintentionally delete a directory which contains content to keep as + /// well as delete (directories which end up empty after this clean up will be removed during the next run) // stringlint:ignore_start let directoryNamesContainingContent: [String] = allAttachmentFilePaths .filter { path -> Bool in path.contains("/") } @@ -451,8 +446,8 @@ public enum GarbageCollectionJob: JobExecutor { // stringlint:ignore_stop orphanedAttachmentFiles.forEach { filepath in - // We don't want a single deletion failure to block deletion of the other files so try - // each one and store the error to be used to determine success/failure of the job + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job do { try dependencies[singleton: .fileManager].removeItem( atPath: URL(fileURLWithPath: Attachment.attachmentsFolder(using: dependencies)) @@ -466,7 +461,7 @@ public enum GarbageCollectionJob: JobExecutor { Log.info(.cat, "Orphaned attachments removed: \(orphanedAttachmentFiles.count)") } - // Orphaned display picture files (actual deletion) + /// Orphaned display picture files (actual deletion) if finalTypesToCollect.contains(.orphanedDisplayPictures) { let allDisplayPictureFilenames: Set = (try? dependencies[singleton: .fileManager] .contentsOfDirectory(atPath: dependencies[singleton: .displayPictureManager].sharedDataDisplayPictureDirPath())) @@ -476,8 +471,8 @@ public enum GarbageCollectionJob: JobExecutor { .subtracting(fileInfo.displayPictureFilenames) orphanedFiles.forEach { filename in - // We don't want a single deletion failure to block deletion of the other files so try - // each one and store the error to be used to determine success/failure of the job + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job do { try dependencies[singleton: .fileManager].removeItem( atPath: dependencies[singleton: .displayPictureManager].filepath(for: filename) @@ -489,19 +484,55 @@ public enum GarbageCollectionJob: JobExecutor { Log.info(.cat, "Orphaned display pictures removed: \(orphanedFiles.count)") } - // Report a single file deletion as a job failure (even if other content was successfully removed) + /// Explicit deduplication records that we want to delete + if finalTypesToCollect.contains(.pruneExpiredDeduplicationRecords) { + fileInfo.messageDedupeRecords.forEach { record in + /// We don't want a single deletion failure to block deletion of the other files so try each one and store + /// the error to be used to determine success/failure of the job + do { + try dependencies[singleton: .extensionHelper].removeDedupeRecord( + threadId: record.threadId, + uniqueIdentifier: record.uniqueIdentifier + ) + } + catch { deletionErrors.append(error) } + } + } + + /// Report a single file deletion as a job failure (even if other content was successfully removed) guard deletionErrors.isEmpty else { failure(job, (deletionErrors.first ?? StorageError.generic), false) return } - // If we did a full collection then update the 'lastGarbageCollection' date to - // prevent a full collection from running again in the next 23 hours - if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { - dependencies[defaults: .standard, key: .lastGarbageCollection] = dependencies.dateNow + /// Define a `successClosure` to avoid duplication + let successClosure: () -> Void = { + /// If we did a full collection then update the `lastGarbageCollection` date to prevent a full collection + /// from running again in the next 23 hours + if job.behaviour == .recurringOnActive && dependencies.dateNow.timeIntervalSince(lastGarbageCollection) > (23 * 60 * 60) { + dependencies[defaults: .standard, key: .lastGarbageCollection] = dependencies.dateNow + } + + success(job, false) } - success(job, false) + /// Since the explicit file deletion was successful we can now _actually_ delete the `MessageDeduplication` + /// entries from the database (we don't do this until after the files have been removed to ensure we don't orphan + /// files by doing so) + guard !fileInfo.messageDedupeRecords.isEmpty else { return successClosure() } + + dependencies[singleton: .storage] + .writeAsync( + updates: { db in + try fileInfo.messageDedupeRecords.forEach { try $0.delete(db) } + }, + completion: { result in + switch result { + case .failure: failure(job, StorageError.generic, false) + case .success: successClosure() + } + } + ) } } ) @@ -512,7 +543,6 @@ public enum GarbageCollectionJob: JobExecutor { extension GarbageCollectionJob { public enum Types: Codable, CaseIterable { - case expiredControlMessageProcessRecords case threadTypingIndicators case oldOpenGroupMessages case orphanedJobs @@ -529,6 +559,7 @@ extension GarbageCollectionJob { case expiredPendingReadReceipts case shadowThreads case pruneExpiredLastHashRecords + case pruneExpiredDeduplicationRecords } public struct Details: Codable { diff --git a/SessionMessagingKit/Jobs/GroupLeavingJob.swift b/SessionMessagingKit/Jobs/GroupLeavingJob.swift index c45bbdc145..5092827cf9 100644 --- a/SessionMessagingKit/Jobs/GroupLeavingJob.swift +++ b/SessionMessagingKit/Jobs/GroupLeavingJob.swift @@ -57,13 +57,11 @@ public enum GroupLeavingJob: JobExecutor { .defaulting(to: 0) let finalBehaviour: GroupLeavingJob.Details.Behaviour = { guard - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) || - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies + ( + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) || + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + } ) else { return details.behaviour } diff --git a/SessionMessagingKit/Jobs/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/MessageReceiveJob.swift index df2980c8a2..2ec3aff2b4 100644 --- a/SessionMessagingKit/Jobs/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/MessageReceiveJob.swift @@ -76,7 +76,6 @@ public enum MessageReceiveJob: JobExecutor { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -208,8 +207,8 @@ extension MessageReceiveJob { public init(messages: [ProcessedMessage]) { self.messages = messages.compactMap { processedMessage in switch processedMessage { - case .config: return nil - case .standard(_, _, _, let messageInfo): return messageInfo + case .config, .invalid: return nil + case .standard(_, _, _, let messageInfo, _): return messageInfo } } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index 7b08418bb2..68b2eb6b11 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -43,7 +43,6 @@ internal extension LibSessionCacheType { // The current users contact data is handled separately so exclude it if it's present (as that's // actually a bug) - let userSessionId: SessionId = dependencies[cache: .general].sessionId let targetContactData: [String: ContactData] = try LibSession.extractContacts( from: conf, serverTimestampMs: serverTimestampMs, @@ -661,6 +660,26 @@ public extension LibSession { } } +// MARK: - State Access + +public extension LibSession.Cache { + func isContactBlocked(contactId: String) -> Bool { + guard + case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId), + var cContactId: [CChar] = contactId.cString(using: .utf8) + else { return false } + + var contact: contacts_contact = contacts_contact() + + guard contacts_get(conf, &contact, &cContactId) else { + LibSessionError.clear(conf) + return false + } + + return contact.blocked + } +} + // MARK: - SyncedContactInfo extension LibSession { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift index 22f299e260..f9bd33fb0f 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+ConvoInfoVolatile.swift @@ -405,11 +405,13 @@ public extension LibSession { } } +// MARK: State Access + public extension LibSessionCacheType { func conversationLastRead( threadId: String, threadVariant: SessionThread.Variant, - openGroup: OpenGroup? + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Int64? { // If we don't have a config then just assume it's unread guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { @@ -443,13 +445,11 @@ public extension LibSessionCacheType { return legacyGroup.last_read case .community: - guard let openGroup: OpenGroup = openGroup else { return nil } - var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() guard - var cBaseUrl: [CChar] = openGroup.server.cString(using: .utf8), - var cRoomToken: [CChar] = openGroup.roomToken.cString(using: .utf8), + var cBaseUrl: [CChar] = openGroupUrlInfo?.server.cString(using: .utf8), + var cRoomToken: [CChar] = openGroupUrlInfo?.roomToken.cString(using: .utf8), convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) else { LibSessionError.clear(conf) @@ -474,95 +474,21 @@ public extension LibSessionCacheType { threadId: String, threadVariant: SessionThread.Variant, timestampMs: Int64, - userSessionId: SessionId, - openGroup: OpenGroup? + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? ) -> Bool { - // If we don't have a config then just assume it's unread - guard case .convoInfoVolatile(let conf) = config(for: .convoInfoVolatile, sessionId: userSessionId) else { - return false - } - - switch threadVariant { - case .contact: - var oneToOne: convo_info_volatile_1to1 = convo_info_volatile_1to1() - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_1to1(conf, &oneToOne, &cThreadId) - else { - LibSessionError.clear(conf) - return false - } - - return (oneToOne.last_read >= timestampMs) - - case .legacyGroup: - var legacyGroup: convo_info_volatile_legacy_group = convo_info_volatile_legacy_group() - - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_legacy_group(conf, &legacyGroup, &cThreadId) - else { - LibSessionError.clear(conf) - return false - } - - return (legacyGroup.last_read >= timestampMs) - - case .community: - guard let openGroup: OpenGroup = openGroup else { return false } - - var convoCommunity: convo_info_volatile_community = convo_info_volatile_community() - - guard - var cBaseUrl: [CChar] = openGroup.server.cString(using: .utf8), - var cRoomToken: [CChar] = openGroup.roomToken.cString(using: .utf8), - convo_info_volatile_get_community(conf, &convoCommunity, &cBaseUrl, &cRoomToken) - else { - LibSessionError.clear(conf) - return false - } - - return (convoCommunity.last_read >= timestampMs) - - case .group: - var group: convo_info_volatile_group = convo_info_volatile_group() - - guard - var cThreadId: [CChar] = threadId.cString(using: .utf8), - convo_info_volatile_get_group(conf, &group, &cThreadId) - else { return false } - - return (group.last_read >= timestampMs) - } + let lastReadTimestampMs = conversationLastRead( + threadId: threadId, + threadVariant: threadVariant, + openGroupUrlInfo: openGroupUrlInfo + ) + + return ((lastReadTimestampMs ?? 0) >= timestampMs) } } // MARK: - VolatileThreadInfo public extension LibSession { - struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { - let threadId: String - let server: String - let roomToken: String - let publicKey: String - - static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? { - return try OpenGroup - .filter(id: id) - .select(.threadId, .server, .roomToken, .publicKey) - .asRequest(of: OpenGroupUrlInfo.self) - .fetchOne(db) - } - - static func fetchAll(_ db: Database, ids: [String]) throws -> [OpenGroupUrlInfo] { - return try OpenGroup - .filter(ids: ids) - .select(.threadId, .server, .roomToken, .publicKey) - .asRequest(of: OpenGroupUrlInfo.self) - .fetchAll(db) - } - } - struct VolatileThreadInfo { enum Change { case markedAsUnread(Bool) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift index 785ef39f0a..d08bd473f1 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupInfo.swift @@ -310,7 +310,11 @@ internal extension LibSession { // admin (non-admins can't update `GroupInfo` anyway) let targetGroups: [ClosedGroup] = updatedGroups .filter { (try? SessionId(from: $0.id))?.prefix == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) } + .filter { group in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: group.id)) + }) + } // If we only updated the current user contact then no need to continue guard !targetGroups.isEmpty else { return updated } @@ -361,7 +365,11 @@ internal extension LibSession { // the current user isn't an admin (non-admins can't update `GroupInfo` anyway) let targetUpdatedConfigs: [DisappearingMessagesConfiguration] = updatedDisappearingConfigs .filter { (try? SessionId.Prefix(from: $0.id)) == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.id), using: dependencies) } + .filter { group in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: group.id)) + }) + } guard !targetUpdatedConfigs.isEmpty else { return updated } @@ -461,26 +469,17 @@ public extension LibSessionCacheType { } } -// MARK: - Direct Values +// MARK: - State Access -extension LibSession { - static func groupName(in config: Config?) throws -> String { - guard - case .groupInfo(let conf) = config, - let groupNamePtr: UnsafePointer = groups_info_get_name(conf) - else { throw LibSessionError.invalidConfigObject } - - return String(cString: groupNamePtr) - } - - static func groupDeleteBefore(in config: Config?) throws -> TimeInterval { - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } +public extension LibSession.Cache { + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { + guard case .groupInfo(let conf) = config(for: .groupInfo, sessionId: groupSessionId) else { return nil } return TimeInterval(groups_info_get_delete_before(conf)) } - static func groupAttachmentDeleteBefore(in config: Config?) throws -> TimeInterval { - guard case .groupInfo(let conf) = config else { throw LibSessionError.invalidConfigObject } + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { + guard case .groupInfo(let conf) = config(for: .groupInfo, sessionId: groupSessionId) else { return nil } return TimeInterval(groups_info_get_attach_delete_before(conf)) } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift index 1ceceebd39..1ba97b10f7 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupKeys.swift @@ -72,6 +72,21 @@ internal extension LibSessionCacheType { // MARK: - Outgoing Changes +public extension LibSession.Cache { + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId, + ) throws { + guard case .groupKeys(let conf, let infoConf, let membersConf) = config(for: .groupKeys, sessionId: groupSessionId) else { + throw LibSessionError.invalidConfigObject + } + + var identitySeed: [UInt8] = Array(groupIdentitySeed) + groups_keys_load_admin_key(conf, &identitySeed, infoConf, membersConf) + try LibSessionError.throwIfNeeded(conf) + } +} + internal extension LibSession { static func rekey( _ db: Database, @@ -140,14 +155,8 @@ internal extension LibSession { try dependencies.mutate(cache: .libSession) { cache in /// Disable the admin check because we are about to convert the user to being an admin and it's guaranteed to fail try cache.withCustomBehaviour(.skipGroupAdminCheck, for: groupSessionId) { - try cache.performAndPushChange(db, for: .groupKeys, sessionId: groupSessionId) { config in - guard case .groupKeys(let conf, let infoConf, let membersConf) = config else { - throw LibSessionError.invalidConfigObject - } - - var identitySeed: [UInt8] = Array(groupIdentitySeed) - groups_keys_load_admin_key(conf, &identitySeed, infoConf, membersConf) - try LibSessionError.throwIfNeeded(conf) + try cache.performAndPushChange(db, for: .groupKeys, sessionId: groupSessionId) { _ in + try cache.loadAdminKey(groupIdentitySeed: groupIdentitySeed, groupSessionId: groupSessionId) } } } @@ -179,3 +188,15 @@ internal extension LibSession { } } } + +// MARK: - State Accses + +public extension LibSession.Cache { + func isAdmin(groupSessionId: SessionId) -> Bool { + guard case .groupKeys(let conf, _, _) = config(for: .groupKeys, sessionId: groupSessionId) else { + return false + } + + return groups_keys_is_admin(conf) + } +} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift index 60bb37583b..55f810cb0c 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+GroupMembers.swift @@ -34,7 +34,6 @@ internal extension LibSessionCacheType { guard case .groupMembers(let conf) = config else { throw LibSessionError.invalidConfigObject } // Get the two member sets - let userSessionId: SessionId = dependencies[cache: .general].sessionId let updatedMembers: Set = try LibSession.extractMembers(from: conf, groupSessionId: groupSessionId) let existingMembers: Set = (try? GroupMember .filter(GroupMember.Columns.groupId == groupSessionId.hexString) @@ -368,7 +367,11 @@ internal extension LibSession { // isn't an admin (non-admins can't update `GroupMembers` anyway) let targetMembers: [GroupMember] = updatedMembers .filter { (try? SessionId(from: $0.groupId))?.prefix == .group } - .filter { isAdmin(groupSessionId: SessionId(.group, hex: $0.groupId), using: dependencies) } + .filter { member in + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: SessionId(.group, hex: member.groupId)) + }) + } // If we only updated the current user contact then no need to continue guard diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 9188e5e8a4..87e1b216e5 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -27,9 +27,9 @@ public extension LibSession { internal extension LibSession { /// This is a buffer period within which we will process messages which would result in a config change, any message which would normally - /// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriod` will not + /// result in a config change which was sent before `lastConfigMessage.timestamp - configChangeBufferPeriodMs` will not /// actually have it's changes applied (info messages would still be inserted though) - static let configChangeBufferPeriod: TimeInterval = (2 * 60) + static let configChangeBufferPeriodMs: Int64 = ((2 * 60) * 1000) static let columnsRelatedToThreads: [ColumnExpression] = [ SessionThread.Columns.pinnedPriority, @@ -331,37 +331,6 @@ internal extension LibSession { } } - static func canPerformChange( - _ db: Database, - threadId: String, - targetConfig: ConfigDump.Variant, - changeTimestampMs: Int64, - using dependencies: Dependencies - ) -> Bool { - let targetSessionId: String = { - switch targetConfig { - case .userProfile, .contacts, .convoInfoVolatile, .userGroups: - return dependencies[cache: .general].sessionId.hexString - - case .groupInfo, .groupMembers, .groupKeys: return threadId - case .invalid: return "" - } - }() - - let configDumpTimestampMs: Int64 = (try? ConfigDump - .filter( - ConfigDump.Columns.variant == targetConfig && - ConfigDump.Columns.sessionId == targetSessionId - ) - .select(.timestampMs) - .asRequest(of: Int64.self) - .fetchOne(db)) - .defaulting(to: 0) - - // Ensure the change occurred after the last config message was handled (minus the buffer period) - return (changeTimestampMs >= (configDumpTimestampMs - Int64(LibSession.configChangeBufferPeriod * 1000))) - } - static func checkLoopLimitReached(_ loopCounter: inout Int, for variant: ConfigDump.Variant, maxLoopCount: Int = 50000) throws { loopCounter += 1 @@ -374,22 +343,267 @@ internal extension LibSession { // MARK: - State Access -extension LibSession.Config { - public func pinnedPriority( - _ db: Database, +public extension LibSession.Cache { + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { + let variant: ConfigDump.Variant = { + switch threadVariant { + case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) + case .legacyGroup, .group, .community: return .userGroups + } + }() + + let configDumpTimestamp: TimeInterval = dependencies[singleton: .extensionHelper] + .lastUpdatedTimestamp(for: userSessionId, variant: variant) + let configDumpTimestampMs: Int64 = Int64(configDumpTimestamp * 1000) + + /// Ensure the change occurred after the last config message was handled (minus the buffer period) + return (changeTimestampMs >= (configDumpTimestampMs - LibSession.configChangeBufferPeriodMs)) + } + + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { + // Currently blinded conversations cannot be contained in the config, so there is no + // point checking (it'll always be false) + guard + threadVariant == .community || ( + (try? SessionId(from: threadId))?.prefix != .blinded15 && + (try? SessionId(from: threadId))?.prefix != .blinded25 + ), + var cThreadId: [CChar] = threadId.cString(using: .utf8) + else { return false } + + switch threadVariant { + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return false + } + + return ( + !visibleOnly || + LibSession.shouldBeVisible(priority: user_profile_get_nts_priority(conf)) + ) + + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return false + } + guard contacts_get(conf, &contact, &cThreadId) else { + LibSessionError.clear(conf) + return false + } + + /// If the user opens a conversation with an existing contact but doesn't send them a message + /// then the one-to-one conversation should remain hidden so we want to delete the `SessionThread` + /// when leaving the conversation + return (!visibleOnly || LibSession.shouldBeVisible(priority: contact.priority)) + + case .community: + guard + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, + var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) + else { return false } + + var community: ugroups_community_info = ugroups_community_info() + + /// Not handling the `hidden` behaviour for communities so just indicate the existence + let result: Bool = user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) + LibSessionError.clear(conf) + + return result + + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return false + } + + var group: ugroups_group_info = ugroups_group_info() + + /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence + return user_groups_get_group(conf, &group, &cThreadId) + + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return false + } + + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) + LibSessionError.clear(conf) + + /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence + if groupInfo != nil { + ugroups_legacy_group_free(groupInfo) + return true + } + + return false + } + } + + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { + var finalProfile: Profile? = contactProfile + var finalOpenGroupName: String? = openGroupName + var finalClosedGroupName: String? + + switch threadVariant { + case .contact where threadId == userSessionId.hexString: break + case .contact: + guard contactProfile == nil else { break } + + finalProfile = profile( + threadId: threadId, + threadVariant: threadVariant, + contactId: threadId, + visibleMessage: visibleMessage + ) + + case .community: + guard + openGroupName == nil, + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, + var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) + else { break } + + var community: ugroups_community_info = ugroups_community_info() + + guard user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) else { + LibSessionError.clear(conf) + break + } + + finalOpenGroupName = community.get(\.room).nullIfEmpty + + case .group: + guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { break } + + /// For a group try to extract the name from a `GroupInfo` config first, falling back to the `UserGroups` config + guard + case .groupInfo(let conf) = config(for: .groupInfo, sessionId: SessionId(.group, hex: threadId)), + let groupNamePtr: UnsafePointer = groups_info_get_name(conf) + else { + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + break + } + + var group: ugroups_group_info = ugroups_group_info() + + guard user_groups_get_group(conf, &group, &cThreadId) else { + LibSessionError.clear(conf) + break + } + + finalClosedGroupName = group.get(\.name).nullIfEmpty + break + } + + finalClosedGroupName = String(cString: groupNamePtr) + + case .legacyGroup: + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cThreadId: [CChar] = threadId.cString(using: .utf8) + else { break } + + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) + LibSessionError.clear(conf) + + defer { + if groupInfo != nil { + ugroups_legacy_group_free(groupInfo) + } + } + + finalClosedGroupName = groupInfo?.get(\.name).nullIfEmpty + } + + return SessionThread.displayName( + threadId: threadId, + variant: threadVariant, + closedGroupName: finalClosedGroupName, + openGroupName: finalOpenGroupName, + isNoteToSelf: (threadId == userSessionId.hexString), + profile: finalProfile + ) + } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { + ) -> Bool { + guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return true } + + switch threadVariant { + case .community, .legacyGroup: return false + case .contact where threadId == userSessionId.hexString: return false + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return true + } + guard contacts_get(conf, &contact, &cThreadId) else { + LibSessionError.clear(conf) + return true + } + + return !contact.approved + + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return true + } + + var group: ugroups_group_info = ugroups_group_info() + _ = user_groups_get_group(conf, &group, &cThreadId) + LibSessionError.clear(conf) + + return group.invited + } + } + + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return LibSession.defaultNewThreadPriority } - switch (threadVariant, self) { - case (_, .userProfile(let conf)): return user_profile_get_nts_priority(conf) + switch threadVariant { + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + + return user_profile_get_nts_priority(conf) - case (_, .contacts(let conf)): + case .contact: var contact: contacts_contact = contacts_contact() + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } guard contacts_get(conf, &contact, &cThreadId) else { LibSessionError.clear(conf) return LibSession.defaultNewThreadPriority @@ -397,11 +611,12 @@ extension LibSession.Config { return contact.priority - case (.community, .userGroups(let conf)): + case .community: guard - let urlInfo: LibSession.OpenGroupUrlInfo = try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId), + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), - var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8) + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { return LibSession.defaultNewThreadPriority } var community: ugroups_community_info = ugroups_community_info() @@ -410,7 +625,11 @@ extension LibSession.Config { return community.priority - case (.legacyGroup, .userGroups(let conf)): + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) LibSessionError.clear(conf) @@ -422,28 +641,117 @@ extension LibSession.Config { return (groupInfo?.pointee.priority ?? LibSession.defaultNewThreadPriority) - case (.group, .userGroups(let conf)): + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return LibSession.defaultNewThreadPriority + } + var group: ugroups_group_info = ugroups_group_info() _ = user_groups_get_group(conf, &group, &cThreadId) LibSessionError.clear(conf) return group.priority + } + } + + func notificationSettings( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Preferences.NotificationSettings { + guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { + return .defaultFor(threadVariant) + } + + // TODO: [Database Relocation] Need to add this to local libSession +// sound = (db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) + let sound: Preferences.Sound = .defaultNotificationSound + + // TODO: [Database Relocation] Need to add this to local libSession +// let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] +// .defaulting(to: .defaultPreviewType) + + switch threadVariant { + case .legacyGroup: return .defaultFor(threadVariant) + case .contact where threadId == userSessionId.hexString: return .defaultFor(.contact) + case .contact: + var contact: contacts_contact = contacts_contact() + + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return .defaultFor(threadVariant) + } + guard contacts_get(conf, &contact, &cThreadId) else { + LibSessionError.clear(conf) + return .defaultFor(threadVariant) + } + + return Preferences.NotificationSettings( + mode: Preferences.NotificationMode( + libSessionValue: contact.notifications, + threadVariant: threadVariant + ), + previewType: .defaultPreviewType, // TODO: [Database Relocation] Add this + sound: sound, + mutedUntil: (contact.mute_until > 0 ? TimeInterval(contact.mute_until) : nil) + ) + - default: - Log.warn(.libSession, "Attempted to retrieve priority for invalid combination of threadVariant: \(threadVariant) and config variant: \(variant)") - return LibSession.defaultNewThreadPriority + case .community: + guard + let urlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo, + var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), + var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8), + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) + else { return .defaultFor(threadVariant) } + + var community: ugroups_community_info = ugroups_community_info() + _ = user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) + LibSessionError.clear(conf) + + return Preferences.NotificationSettings( + mode: Preferences.NotificationMode( + libSessionValue: community.notifications, + threadVariant: threadVariant + ), + previewType: .defaultPreviewType, // TODO: [Database Relocation] Add this + sound: sound, + mutedUntil: (community.mute_until > 0 ? TimeInterval(community.mute_until) : nil) + ) + + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return .defaultFor(threadVariant) + } + + var group: ugroups_group_info = ugroups_group_info() + _ = user_groups_get_group(conf, &group, &cThreadId) + LibSessionError.clear(conf) + + return Preferences.NotificationSettings( + mode: Preferences.NotificationMode( + libSessionValue: group.notifications, + threadVariant: threadVariant + ), + previewType: .defaultPreviewType, // TODO: [Database Relocation] Add this + sound: sound, + mutedUntil: (group.mute_until > 0 ? TimeInterval(group.mute_until) : nil) + ) } } - public func disappearingMessagesConfig( + func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return nil } - switch (threadVariant, self) { - case (.community, _): return nil - case (_, .userProfile(let conf)): + switch threadVariant { + case .community: return nil + case .contact where threadId == userSessionId.hexString: + guard case .userProfile(let conf) = config(for: .userProfile, sessionId: userSessionId) else { + return nil + } + let targetExpiry: Int32 = user_profile_get_nts_expiry(conf) let targetIsEnabled: Bool = (targetExpiry > 0) @@ -454,9 +762,12 @@ extension LibSession.Config { type: targetIsEnabled ? .disappearAfterSend : .unknown ) - case (_, .contacts(let conf)): + case .contact: var contact: contacts_contact = contacts_contact() + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return nil + } guard contacts_get(conf, &contact, &cThreadId) else { LibSessionError.clear(conf) return nil @@ -471,7 +782,11 @@ extension LibSession.Config { ) ) - case (.legacyGroup, .userGroups(let conf)): + case .legacyGroup: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return nil + } + let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) LibSessionError.clear(conf) @@ -490,7 +805,11 @@ extension LibSession.Config { ) } - case (.group, .groupInfo(let conf)): + case .group: + guard case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId) else { + return nil + } + let durationSeconds: Int32 = groups_info_get_expiry_timer(conf) return DisappearingMessagesConfiguration( @@ -499,108 +818,100 @@ extension LibSession.Config { durationSeconds: TimeInterval(durationSeconds), type: .disappearAfterSend ) - - default: - Log.warn(.libSession, "Attempted to retrieve disappearing messages config for invalid combination of threadVariant: \(threadVariant) and config variant: \(variant)") - return nil } } - public func isAdmin() -> Bool { - guard case .groupKeys(let conf, _, _) = self else { return false } - - return groups_keys_is_admin(conf) - } -} - -public extension LibSession { - static func conversationInConfig( - _ db: Database, + func profile( threadId: String, threadVariant: SessionThread.Variant, - visibleOnly: Bool, - using dependencies: Dependencies - ) -> Bool { - // Currently blinded conversations cannot be contained in the config, so there is no - // point checking (it'll always be false) - guard - threadVariant == .community || ( - (try? SessionId(from: threadId))?.prefix != .blinded15 && - (try? SessionId(from: threadId))?.prefix != .blinded25 - ) - else { return false } + contactId: String, + visibleMessage: VisibleMessage? + ) -> Profile? { + // FIXME: Once `libSession` manages unsynced "Profile" data we should source this from there + /// Extract the `displayName` directly from the `VisibleMessage` if available + let displayNameInMessage: String? = visibleMessage?.profile?.displayName?.nullIfEmpty + let fallbackProfile: Profile? = displayNameInMessage.map { Profile(id: contactId, name: $0) } - let userSessionId: SessionId = dependencies[cache: .general].sessionId - let configVariant: ConfigDump.Variant = { - switch threadVariant { - case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) - case .legacyGroup, .group, .community: return .userGroups + guard var cContactId: [CChar] = contactId.cString(using: .utf8) else { + return fallbackProfile + } + + /// Define a function to extract a profile from the `GroupMembers` config, if we can't get a direct name for the contact and it's + /// a group conversation then be might be able to source it from there + func extractGroupMembersProfile() -> Profile? { + guard + threadVariant == .group, + case .groupMembers(let conf) = config(for: .contacts, sessionId: SessionId(.group, hex: threadId)) + else { return nil } + + var member: config_group_member = config_group_member() + + guard groups_members_get(conf, &member, &cContactId) else { + LibSessionError.clear(conf) + return fallbackProfile } - }() + + let profilePictureUrl: String? = member.get(\.profile_pic.url, nullIfEmpty: true) + + /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + return Profile( + id: contactId, + name: (displayNameInMessage ?? member.get(\.name)), + lastNameUpdate: nil, + nickname: nil, + profilePictureUrl: profilePictureUrl, + profileEncryptionKey: (profilePictureUrl == nil ? nil : member.get(\.profile_pic.key)), + lastProfilePictureUpdate: nil + ) + } + + /// Try to extract profile information from the `Contacts` config + guard case .contacts(let conf) = config(for: .contacts, sessionId: userSessionId) else { + return extractGroupMembersProfile() + } + + var contact: contacts_contact = contacts_contact() - return dependencies.mutate(cache: .libSession) { cache in - guard var cThreadId: [CChar] = threadId.cString(using: .utf8) else { return false } + guard contacts_get(conf, &contact, &cContactId) else { + LibSessionError.clear(conf) + return extractGroupMembersProfile() + } + + let profilePictureUrl: String? = contact.get(\.profile_pic.url, nullIfEmpty: true) + + /// The `displayNameInMessage` value is likely newer than the `name` value in the config so use that if available + return Profile( + id: contactId, + name: (displayNameInMessage ?? contact.get(\.name)), + lastNameUpdate: nil, + nickname: contact.get(\.nickname, nullIfEmpty: true), + profilePictureUrl: profilePictureUrl, + profileEncryptionKey: (profilePictureUrl == nil ? nil : contact.get(\.profile_pic.key)), + lastProfilePictureUpdate: nil + ) + } + + func groupName(groupSessionId: SessionId) -> String? { + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) + else { return nil } + + var group: ugroups_group_info = ugroups_group_info() + + guard user_groups_get_group(conf, &group, &cGroupId) else { + LibSessionError.clear(conf) - switch (threadVariant, cache.config(for: configVariant, sessionId: userSessionId)) { - case (_, .userProfile(let conf)): - return ( - !visibleOnly || - LibSession.shouldBeVisible(priority: user_profile_get_nts_priority(conf)) - ) - - case (_, .contacts(let conf)): - var contact: contacts_contact = contacts_contact() - - guard contacts_get(conf, &contact, &cThreadId) else { - LibSessionError.clear(conf) - return false - } - - /// If the user opens a conversation with an existing contact but doesn't send them a message - /// then the one-to-one conversation should remain hidden so we want to delete the `SessionThread` - /// when leaving the conversation - return (!visibleOnly || LibSession.shouldBeVisible(priority: contact.priority)) - - case (.community, .userGroups(let conf)): - let maybeUrlInfo: OpenGroupUrlInfo? = (try? OpenGroupUrlInfo - .fetchAll(db, ids: [threadId]))? - .first - - guard - let urlInfo: OpenGroupUrlInfo = maybeUrlInfo, - var cBaseUrl: [CChar] = urlInfo.server.cString(using: .utf8), - var cRoom: [CChar] = urlInfo.roomToken.cString(using: .utf8) - else { return false } - - var community: ugroups_community_info = ugroups_community_info() - - /// Not handling the `hidden` behaviour for communities so just indicate the existence - let result: Bool = user_groups_get_community(conf, &community, &cBaseUrl, &cRoom) - LibSessionError.clear(conf) - - return result - - case (.legacyGroup, .userGroups(let conf)): - let groupInfo: UnsafeMutablePointer? = user_groups_get_legacy_group(conf, &cThreadId) - LibSessionError.clear(conf) - - /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence - if groupInfo != nil { - ugroups_legacy_group_free(groupInfo) - return true - } - - return false - - case (.group, .userGroups(let conf)): - var group: ugroups_group_info = ugroups_group_info() - - /// Not handling the `hidden` behaviour for legacy groups so just indicate the existence - return user_groups_get_group(conf, &group, &cThreadId) - - default: return false + guard let legacyGroup: UnsafeMutablePointer = user_groups_get_legacy_group(conf, &cGroupId) else { + LibSessionError.clear(conf) + return nil } + + defer { ugroups_legacy_group_free(legacyGroup) } + return legacyGroup.get(\.name) } + + return group.get(\.name) } } @@ -630,17 +941,6 @@ internal extension LibSession { } } -// MARK: - PriorityVisibilityInfo - -extension LibSession { - struct PriorityVisibilityInfo: Codable, FetchableRecord, Identifiable { - let id: String - let variant: SessionThread.Variant - let pinnedPriority: Int32? - let shouldBeVisible: Bool - } -} - // MARK: - LibSessionRespondingViewController public protocol LibSessionRespondingViewController { @@ -656,3 +956,18 @@ public extension LibSessionRespondingViewController { func isConversation(in threadIds: [String]) -> Bool { return false } func forceRefreshIfNeeded() {} } + +// MARK: - Preferences.NotificationMode + +private extension Preferences.NotificationMode { + init(libSessionValue: CONVO_NOTIFY_MODE, threadVariant: SessionThread.Variant) { + switch libSessionValue { + case CONVO_NOTIFY_DEFAULT: self = .defaultMode(for: threadVariant) + case CONVO_NOTIFY_ALL: self = .all + case CONVO_NOTIFY_DISABLED: self = .none + case CONVO_NOTIFY_MENTIONS_ONLY: self = .mentionsOnly + + default: self = .defaultMode(for: threadVariant) + } + } +} diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 254b88007c..172d57f7bb 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -18,12 +18,12 @@ public extension LibSession.Crypto.Domain { internal extension LibSessionCacheType { @discardableResult func createAndLoadGroupState( groupSessionId: SessionId, - userED25519KeyPair: KeyPair, + userED25519SecretKey: [UInt8], groupIdentityPrivateKey: Data? ) throws -> [ConfigDump.Variant: LibSession.Config] { let groupState: [ConfigDump.Variant: LibSession.Config] = try LibSession.createGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: userED25519SecretKey, groupIdentityPrivateKey: groupIdentityPrivateKey ) @@ -61,7 +61,7 @@ internal extension LibSession { ) throws -> CreatedGroupInfo { guard let groupIdentityKeyPair: KeyPair = dependencies[singleton: .crypto].generate(.ed25519KeyPair()), - let userED25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + !dependencies[cache: .general].ed25519SecretKey.isEmpty else { throw MessageSenderError.noKeyPair } // Prep the relevant details (reduce the members to ensure we don't accidentally insert duplicates) @@ -73,7 +73,7 @@ internal extension LibSession { // Create the new config objects let groupState: [ConfigDump.Variant: Config] = try createGroupState( groupSessionId: groupSessionId, - userED25519KeyPair: userED25519KeyPair, + userED25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupIdentityPrivateKey: Data(groupIdentityKeyPair.secretKey) ) @@ -194,10 +194,12 @@ internal extension LibSession { static func createGroupState( groupSessionId: SessionId, - userED25519KeyPair: KeyPair, + userED25519SecretKey: [UInt8], groupIdentityPrivateKey: Data? ) throws -> [ConfigDump.Variant: LibSession.Config] { - var secretKey: [UInt8] = userED25519KeyPair.secretKey + guard userED25519SecretKey.count >= 32 else { throw CryptoError.missingUserSecretKey } + + var secretKey: [UInt8] = userED25519SecretKey var groupIdentityPublicKey: [UInt8] = groupSessionId.publicKey // Create the new config objects @@ -335,15 +337,6 @@ internal extension LibSession { using: dependencies ) } - - static func isAdmin( - groupSessionId: SessionId, - using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - return cache.isAdmin(groupSessionId: groupSessionId) - } - } } internal extension LibSessionCacheType { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index b839e45000..629f04b91d 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -432,7 +432,7 @@ internal extension LibSessionCacheType { groupSessionIds: [String], using dependencies: Dependencies ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in + try performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } try groupSessionIds.forEach { groupId in @@ -452,7 +452,7 @@ internal extension LibSessionCacheType { groupSessionIds: [String], using dependencies: Dependencies ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in + try performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } try groupSessionIds.forEach { groupId in @@ -472,7 +472,7 @@ internal extension LibSessionCacheType { groupSessionIds: [String], using dependencies: Dependencies ) throws { - try performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in + try performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } try groupSessionIds.forEach { groupId in @@ -887,44 +887,6 @@ public extension LibSession { } } - static func wasKickedFromGroup( - groupSessionId: SessionId, - using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - guard - case .userGroups(let conf) = cache.config(for: .userGroups, sessionId: dependencies[cache: .general].sessionId), - var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) - else { return false } - - var userGroup: ugroups_group_info = ugroups_group_info() - - // If the group doesn't exist then assume the user hasn't been kicked - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return false } - - return ugroups_group_is_kicked(&userGroup) - } - } - - static func groupIsDestroyed( - groupSessionId: SessionId, - using dependencies: Dependencies - ) -> Bool { - return dependencies.mutate(cache: .libSession) { cache in - guard - case .userGroups(let conf) = cache.config(for: .userGroups, sessionId: dependencies[cache: .general].sessionId), - var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8) - else { return false } - - var userGroup: ugroups_group_info = ugroups_group_info() - - // If the group doesn't exist then assume the user hasn't been kicked - guard user_groups_get_group(conf, &userGroup, &cGroupId) else { return false } - - return ugroups_group_is_destroyed(&userGroup) - } - } - static func remove( _ db: Database, groupSessionIds: [SessionId], @@ -952,6 +914,49 @@ public extension LibSession { } } +// MARK: - State Access + +public extension LibSession.Cache { + func hasCredentials(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the user hasn't been kicked + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return (userGroup.have_auth_data || userGroup.have_secretkey) + } + + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the user hasn't been kicked + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return ugroups_group_is_kicked(&userGroup) + } + + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { + var userGroup: ugroups_group_info = ugroups_group_info() + + /// If the group doesn't exist or a conversion fails then assume the group hasn't been destroyed + guard + case .userGroups(let conf) = config(for: .userGroups, sessionId: userSessionId), + var cGroupId: [CChar] = groupSessionId.hexString.cString(using: .utf8), + user_groups_get_group(conf, &userGroup, &cGroupId) + else { return false } + + return ugroups_group_is_destroyed(&userGroup) + } +} + // MARK: - Convenience public extension LibSession { diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift index 723139a75a..a336d40a10 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserProfile.swift @@ -46,7 +46,6 @@ internal extension LibSessionCacheType { // A profile must have a name so if this is null then it's invalid and can be ignored guard let profileNamePtr: UnsafePointer = user_profile_get_name(conf) else { return } - let userSessionId: SessionId = dependencies[cache: .general].sessionId let profileName: String = String(cString: profileNamePtr) let profilePic: user_profile_pic = user_profile_get_pic(conf) let profilePictureUrl: String? = profilePic.get(\.url, nullIfEmpty: true) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index 7aa25e466d..ea1773d572 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -208,10 +208,9 @@ public extension LibSession { public func loadState(_ db: Database, requestId: String?) { // Ensure we have the ed25519 key and that we haven't already loaded the state before // we continue - guard - configStore.isEmpty, - let ed25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) - else { return Log.warn(.libSession, "Ignoring loadState\(requestId.map { " for \($0)" } ?? "") due to existing state") } + guard configStore.isEmpty else { + return Log.warn(.libSession, "Ignoring loadState\(requestId.map { " for \($0)" } ?? "") due to existing state") + } /// Retrieve the existing dumps from the database typealias ConfigInfo = (sessionId: SessionId, variant: ConfigDump.Variant, dump: ConfigDump?) @@ -274,7 +273,7 @@ public extension LibSession { configStore[sessionId, variant] = try? loadState( for: variant, sessionId: sessionId, - userEd25519SecretKey: ed25519KeyPair.secretKey, + userEd25519SecretKey: dependencies[cache: .general].ed25519SecretKey, groupEd25519SecretKey: groupsByKey[sessionId.hexString]? .groupIdentityPrivateKey .map { Array($0) }, @@ -307,6 +306,8 @@ public extension LibSession { groupEd25519SecretKey: [UInt8]?, cachedData: Data? ) throws -> Config { + guard userEd25519SecretKey.count >= 32 else { throw CryptoError.missingUserSecretKey } + var conf: UnsafeMutablePointer? = nil var keysConf: UnsafeMutablePointer? = nil var secretKey: [UInt8] = userEd25519SecretKey @@ -561,14 +562,10 @@ public extension LibSession { } } - public func pendingChanges( - _ db: Database, - swarmPublicKey: String - ) throws -> PendingChanges { - guard Identity.userExists(db, using: dependencies) else { throw LibSessionError.userDoesNotExist } + public func pendingChanges(swarmPublicKey: String) throws -> PendingChanges { + guard dependencies[cache: .general].userExists else { throw LibSessionError.userDoesNotExist } // Get a list of the different config variants for the provided publicKey - let userSessionId: SessionId = dependencies[cache: .general].sessionId let targetSessionId: SessionId = try SessionId(from: swarmPublicKey) let targetVariants: [(sessionId: SessionId, variant: ConfigDump.Variant)] = { switch (swarmPublicKey, targetSessionId) { @@ -667,10 +664,10 @@ public extension LibSession { .reduce([], +) } - public func handleConfigMessages( - _ db: Database, + public func mergeConfigMessages( swarmPublicKey: String, - messages: [ConfigMessageReceiveJob.Details.MessageInfo] + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64) throws -> Void ) throws { guard !messages.isEmpty else { return } guard !swarmPublicKey.isEmpty else { throw MessageReceiverError.noThread } @@ -689,89 +686,103 @@ public extension LibSession { // to handle the result) guard let latestServerTimestampMs: Int64 = try config?.merge(messages) else { return } - // Apply the updated states to the database - switch variant { - case .userProfile: - try handleUserProfileUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .contacts: - try handleContactsUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .convoInfoVolatile: - try handleConvoInfoVolatileUpdate( - db, - in: config - ) - - case .userGroups: - try handleUserGroupsUpdate( - db, - in: config, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupInfo: - try handleGroupInfoUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupMembers: - try handleGroupMembersUpdate( - db, - in: config, - groupSessionId: sessionId, - serverTimestampMs: latestServerTimestampMs - ) - - case .groupKeys: - try handleGroupKeysUpdate( - db, - in: config, - groupSessionId: sessionId - ) - - case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") - } - - // Need to check if the config needs to be dumped (this might have changed - // after handling the merge changes) - guard configNeedsDump(config) else { - try ConfigDump - .filter( - ConfigDump.Columns.variant == variant && - ConfigDump.Columns.publicKey == sessionId.hexString - ) - .updateAll( - db, - ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) - ) - - return - } - - try createDump( - config: config, - for: variant, - sessionId: sessionId, - timestampMs: latestServerTimestampMs - )?.upsert(db) + // Now that the config message has been merged, run any after-merge logic + try afterMerge(sessionId, variant, config, latestServerTimestampMs) } catch { Log.error(.libSession, "Failed to process merge of \(variant) config data") throw error } } + } + + public func handleConfigMessages( + _ db: Database, + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo] + ) throws { + try mergeConfigMessages( + swarmPublicKey: swarmPublicKey, + messages: messages + ) { sessionId, variant, config, latestServerTimestampMs in + // Apply the updated states to the database + switch variant { + case .userProfile: + try handleUserProfileUpdate( + db, + in: config, + serverTimestampMs: latestServerTimestampMs + ) + + case .contacts: + try handleContactsUpdate( + db, + in: config, + serverTimestampMs: latestServerTimestampMs + ) + + case .convoInfoVolatile: + try handleConvoInfoVolatileUpdate( + db, + in: config + ) + + case .userGroups: + try handleUserGroupsUpdate( + db, + in: config, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupInfo: + try handleGroupInfoUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupMembers: + try handleGroupMembersUpdate( + db, + in: config, + groupSessionId: sessionId, + serverTimestampMs: latestServerTimestampMs + ) + + case .groupKeys: + try handleGroupKeysUpdate( + db, + in: config, + groupSessionId: sessionId + ) + + case .invalid: Log.error(.libSession, "Failed to process merge of invalid config namespace") + } + + // Need to check if the config needs to be dumped (this might have changed + // after handling the merge changes) + guard configNeedsDump(config) else { + try ConfigDump + .filter( + ConfigDump.Columns.variant == variant && + ConfigDump.Columns.publicKey == sessionId.hexString + ) + .updateAll( + db, + ConfigDump.Columns.timestampMs.set(to: latestServerTimestampMs) + ) + + return + } + + try createDump( + config: config, + for: variant, + sessionId: sessionId, + timestampMs: latestServerTimestampMs + )?.upsert(db) + } // Now that the local state has been updated, schedule a config sync if needed (this will // push any pending updates and properly update the state) @@ -802,85 +813,6 @@ public extension LibSession { _ = try configStore[sessionId, variant]?.merge(message) } } - - // MARK: - Value Access - - public func pinnedPriority( - _ db: Database, - threadId: String, - threadVariant: SessionThread.Variant - ) -> Int32? { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch threadVariant { - case .contact where threadId == userSessionId.hexString: - return configStore[userSessionId, .userProfile]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - - case .contact: - return configStore[userSessionId, .contacts]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - - case .community, .group, .legacyGroup: - return configStore[userSessionId, .userGroups]?.pinnedPriority( - db, - threadId: threadId, - threadVariant: threadVariant - ) - } - } - - public func disappearingMessagesConfig( - threadId: String, - threadVariant: SessionThread.Variant - ) -> DisappearingMessagesConfiguration? { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch threadVariant { - case .contact where threadId == userSessionId.hexString: - return configStore[userSessionId, .userProfile]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .contact: - return configStore[userSessionId, .contacts]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .community, .legacyGroup: - return configStore[userSessionId, .userGroups]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - - case .group: - guard - let groupSessionId: SessionId = try? SessionId(from: threadId), - groupSessionId.prefix == .group - else { return nil } - - return configStore[groupSessionId, .groupInfo]?.disappearingMessagesConfig( - threadId: threadId, - threadVariant: threadVariant - ) - } - } - - public func isAdmin(groupSessionId: SessionId) -> Bool { - guard let config: LibSession.Config = configStore[groupSessionId, .groupKeys] else { - return false - } - - return config.isAdmin() - } } } @@ -910,6 +842,10 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT userEd25519KeyPair: KeyPair, groupEd25519SecretKey: [UInt8]? ) + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId, + ) throws func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> LibSession.Config? func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: LibSession.Config) @@ -936,7 +872,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT sessionId: SessionId, change: @escaping (LibSession.Config?) throws -> () ) throws - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges + func pendingChanges(swarmPublicKey: String) throws -> LibSession.PendingChanges func createDumpMarkingAsPushed( data: [(pushData: LibSession.PendingChanges.PushData, hash: String?)], sentTimestamp: Int64, @@ -948,6 +884,11 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func configNeedsDump(_ config: LibSession.Config?) -> Bool func activeHashes(for swarmPublicKey: String) -> [String] + func mergeConfigMessages( + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64) throws -> Void + ) throws func handleConfigMessages( _ db: Database, swarmPublicKey: String, @@ -963,18 +904,65 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws - // MARK: - Value Access + // MARK: - State Access - func pinnedPriority( - _ db: Database, + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String + + /// Returns whether the specified conversation is a message request + /// + /// **Note:** Defaults to `true` on failure + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? + ) -> Bool + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 + func notificationSettings( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Preferences.NotificationSettings func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? + + func isContactBlocked(contactId: String) -> Bool + func profile( + threadId: String, + threadVariant: SessionThread.Variant, + contactId: String, + visibleMessage: VisibleMessage? + ) -> Profile? + + func hasCredentials(groupSessionId: SessionId) -> Bool func isAdmin(groupSessionId: SessionId) -> Bool + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool + func groupName(groupSessionId: SessionId) -> String? + func groupIsDestroyed(groupSessionId: SessionId) -> Bool + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? } public extension LibSessionCacheType { @@ -1005,6 +993,10 @@ private final class NoopLibSessionCache: LibSessionCacheType { userEd25519KeyPair: KeyPair, groupEd25519SecretKey: [UInt8]? ) {} + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId, + ) throws {} func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { return false } func config(for variant: ConfigDump.Variant, sessionId: SessionId) -> LibSession.Config? { return nil } func setConfig(for variant: ConfigDump.Variant, sessionId: SessionId, to config: LibSession.Config) {} @@ -1034,7 +1026,7 @@ private final class NoopLibSessionCache: LibSessionCacheType { change: (LibSession.Config?) throws -> () ) throws {} - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges { + func pendingChanges(swarmPublicKey: String) throws -> LibSession.PendingChanges { return LibSession.PendingChanges() } @@ -1050,6 +1042,11 @@ private final class NoopLibSessionCache: LibSessionCacheType { func configNeedsDump(_ config: LibSession.Config?) -> Bool { return false } func activeHashes(for swarmPublicKey: String) -> [String] { return [] } + func mergeConfigMessages( + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64) throws -> Void + ) throws {} func handleConfigMessages( _ db: Database, swarmPublicKey: String, @@ -1060,18 +1057,62 @@ private final class NoopLibSessionCache: LibSessionCacheType { messages: [ConfigMessageReceiveJob.Details.MessageInfo] ) throws {} - // MARK: - Value Access + // MARK: - State Access - func pinnedPriority( - _ db: Database, + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { return false } + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { return false } + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { return "" } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { return nil } + ) -> Bool { return false } + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { return LibSession.defaultNewThreadPriority } + func notificationSettings( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Preferences.NotificationSettings { return .defaultFor(threadVariant) } func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { return nil } + + func isContactBlocked(contactId: String) -> Bool { return false } + func profile( + threadId: String, + threadVariant: SessionThread.Variant, + contactId: String, + visibleMessage: VisibleMessage? + ) -> Profile? { return nil } + + func hasCredentials(groupSessionId: SessionId) -> Bool { return false } func isAdmin(groupSessionId: SessionId) -> Bool { return false } + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { return false } + func groupName(groupSessionId: SessionId) -> String? { return nil } + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { return false } + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { return nil } } // MARK: - Convenience diff --git a/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift new file mode 100644 index 0000000000..7e7d42f30f --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/OpenGroupUrlInfo.swift @@ -0,0 +1,70 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +public extension LibSession { + // MARK: - OpenGroupUrlInfo + + struct OpenGroupUrlInfo: FetchableRecord, Codable, Hashable { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + + // MARK: - Queries + + public static func fetchOne(_ db: Database, id: String) throws -> OpenGroupUrlInfo? { + return try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + } + + public static func fetchAll(_ db: Database, ids: [String]) throws -> [OpenGroupUrlInfo] { + return try OpenGroup + .filter(ids: ids) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchAll(db) + } + } + + // MARK: - OpenGroupCapabilityInfo + + struct OpenGroupCapabilityInfo: FetchableRecord, Codable, Hashable { + private let urlInfo: OpenGroupUrlInfo + + var threadId: String { urlInfo.threadId } + var server: String { urlInfo.server } + var roomToken: String { urlInfo.roomToken } + var publicKey: String { urlInfo.publicKey } + let capabilities: Set + + // MARK: - Queries + + public static func fetchOne(_ db: Database, id: String) throws -> OpenGroupCapabilityInfo? { + let maybeUrlInfo: OpenGroupUrlInfo? = try OpenGroup + .filter(id: id) + .select(.threadId, .server, .roomToken, .publicKey) + .asRequest(of: OpenGroupUrlInfo.self) + .fetchOne(db) + + guard let urlInfo: OpenGroupUrlInfo = maybeUrlInfo else { return nil } + + let capabilities: Set = (try? Capability + .select(.variant) + .filter(Capability.Columns.openGroupServer == urlInfo.server.lowercased()) + .asRequest(of: Capability.Variant.self) + .fetchSet(db)) + .defaulting(to: []) + + return OpenGroupCapabilityInfo( + urlInfo: urlInfo, + capabilities: capabilities + ) + } + } +} diff --git a/SessionMessagingKit/LibSession/Types/PriorityVisibilityInfo.swift b/SessionMessagingKit/LibSession/Types/PriorityVisibilityInfo.swift new file mode 100644 index 0000000000..1588456d6b --- /dev/null +++ b/SessionMessagingKit/LibSession/Types/PriorityVisibilityInfo.swift @@ -0,0 +1,14 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionUtilitiesKit + +extension LibSession { + struct PriorityVisibilityInfo: Codable, FetchableRecord, Identifiable { + let id: String + let variant: SessionThread.Variant + let pinnedPriority: Int32? + let shouldBeVisible: Bool + } +} diff --git a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift index b6ca42653b..85a06d8e6d 100644 --- a/SessionMessagingKit/Messages/Control Messages/CallMessage.swift +++ b/SessionMessagingKit/Messages/Control Messages/CallMessage.swift @@ -14,6 +14,7 @@ public final class CallMessage: ControlMessage { public var uuid: String public var kind: Kind + public var state: MessageInfo.State? /// See https://developer.mozilla.org/en-US/docs/Glossary/SDP for more information. public var sdps: [String] diff --git a/SessionMessagingKit/Messages/Message+Destination.swift b/SessionMessagingKit/Messages/Message+Destination.swift index cecaae3cb4..507b72c10f 100644 --- a/SessionMessagingKit/Messages/Message+Destination.swift +++ b/SessionMessagingKit/Messages/Message+Destination.swift @@ -28,6 +28,17 @@ public extension Message { /// A message directed to an open group inbox case openGroupInbox(server: String, openGroupPublicKey: String, blindedPublicKey: String) + public var threadVariant: SessionThread.Variant { + switch self { + case .contact, .syncMessage, .openGroupInbox: return .contact + case .closedGroup(let groupId) where (try? SessionId.Prefix(from: groupId)) == .group: + return .group + + case .closedGroup: return .legacyGroup + case .openGroup: return .community + } + } + public var defaultNamespace: SnodeAPI.Namespace? { switch self { case .contact, .syncMessage: return .`default` diff --git a/SessionMessagingKit/Messages/Message.swift b/SessionMessagingKit/Messages/Message.swift index b0371a3dc9..1f1db0b5f7 100644 --- a/SessionMessagingKit/Messages/Message.swift +++ b/SessionMessagingKit/Messages/Message.swift @@ -163,33 +163,46 @@ public enum ProcessedMessage { threadId: String, threadVariant: SessionThread.Variant, proto: SNProtoContent, - messageInfo: MessageReceiveJob.Details.MessageInfo + messageInfo: MessageReceiveJob.Details.MessageInfo, + uniqueIdentifier: String ) case config( publicKey: String, namespace: SnodeAPI.Namespace, serverHash: String, serverTimestampMs: Int64, - data: Data + data: Data, + uniqueIdentifier: String ) + case invalid - var threadId: String { + public var threadId: String { switch self { - case .standard(let threadId, _, _, _): return threadId - case .config(let publicKey, _, _, _, _): return publicKey + case .standard(let threadId, _, _, _, _): return threadId + case .config(let publicKey, _, _, _, _, _): return publicKey + case .invalid: return "" } } var namespace: SnodeAPI.Namespace { switch self { - case .standard(_, let threadVariant, _, _): + case .standard(_, let threadVariant, _, _, _): switch threadVariant { case .group: return .groupMessages case .legacyGroup: return .legacyClosedGroup case .contact, .community: return .default } - case .config(_, let namespace, _, _, _): return namespace + case .config(_, let namespace, _, _, _, _): return namespace + case .invalid: return .default + } + } + + var uniqueIdentifier: String { + switch self { + case .standard(_, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .config(_, _, _, _, _, let uniqueIdentifier): return uniqueIdentifier + case .invalid: return "" } } @@ -197,10 +210,13 @@ public enum ProcessedMessage { switch self { case .standard: return false case .config: return true + case .invalid: return false } } } +// MARK: - Variant + public extension Message { enum Variant: String, Codable, CaseIterable { case readReceipt @@ -335,7 +351,9 @@ public extension Message { } } } - +} + +public extension Message { static func createMessageFrom(_ proto: SNProtoContent, sender: String, using dependencies: Dependencies) throws -> Message { let decodedMessage: Message? = Variant .allCases @@ -355,7 +373,7 @@ public extension Message { case is VisibleMessage: return true case is ExpirationTimerUpdate: return true case is UnsendRequest: return true - + case let callMessage as CallMessage: switch callMessage.kind { case .answer, .endCall: return true @@ -405,148 +423,6 @@ public extension Message { } } - static func processRawReceivedMessage( - _ db: Database, - rawMessage: SnodeReceivedMessage, - swarmPublicKey: String, - shouldStoreMessages: Bool, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - do { - let processedMessage: ProcessedMessage = try processRawReceivedMessage( - db, - data: rawMessage.data, - from: .swarm( - publicKey: swarmPublicKey, - namespace: rawMessage.namespace, - serverHash: rawMessage.info.hash, - serverTimestampMs: rawMessage.timestampMs, - serverExpirationTimestamp: TimeInterval(Double(rawMessage.info.expirationDateMs) / 1000) - ), - using: dependencies - ) - - /// If we don't want to store the messages then don't store any records for deduping purposes - guard shouldStoreMessages else { return processedMessage } - - // Ensure we actually want to de-dupe messages for this namespace, otherwise just - // succeed early - guard rawMessage.namespace.shouldDedupeMessages else { - // If we want to track the last hash then upsert the raw message info (don't - // want to fail if it already exists because we don't want to dedupe messages - // in this namespace) - if rawMessage.namespace.shouldFetchSinceLastHash { - try rawMessage.info.upserted(db) - } - - return processedMessage - } - - // Retrieve the number of entries we have for the hash of this message - let numExistingHashes: Int = (try? SnodeReceivedMessageInfo - .filter(SnodeReceivedMessageInfo.Columns.hash == rawMessage.info.hash) - .fetchCount(db)) - .defaulting(to: 0) - - // Try to insert the raw message info into the database (used for both request paging and - // de-duping purposes) - _ = try rawMessage.info.inserted(db) - - // If the above insertion worked then we hadn't processed this message for this specific - // service node, but may have done so for another node - if the hash already existed in - // the database before we inserted it for this node then we can ignore this message as a - // duplicate - guard numExistingHashes == 0 else { throw MessageReceiverError.duplicateMessageNewSnode } - - return processedMessage - } - catch { - // For some error cases we want to update the last hash so do so - if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { - _ = try? rawMessage.info.inserted(db) - } - - throw error - } - } - - /// This method behaves slightly differently from the other `processRawReceivedMessage` methods as it doesn't - /// insert the "message info" for deduping (we want the poller to re-process the message) and also avoids handling any - /// closed group key update messages (the `NotificationServiceExtension` does this itself) - static func processRawReceivedMessageAsNotification( - _ db: Database, - data: Data, - metadata: PushNotificationAPI.NotificationMetadata, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - return try processRawReceivedMessage( - db, - data: data, - from: .swarm( - publicKey: metadata.accountId, - namespace: metadata.namespace, - serverHash: metadata.hash, - serverTimestampMs: metadata.createdTimestampMs, - serverExpirationTimestamp: ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - ), - using: dependencies - ) - } - - static func processReceivedOpenGroupMessage( - _ db: Database, - openGroupId: String, - openGroupServerPublicKey: String, - message: OpenGroupAPI.Message, - data: Data, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - // Need a sender in order to process the message - guard - let sender: String = message.sender, - let timestamp = message.posted - else { throw MessageReceiverError.invalidMessage } - - return try processRawReceivedMessage( - db, - data: data, - from: .community( - openGroupId: openGroupId, - sender: sender, - timestamp: timestamp, - messageServerId: message.id, - whisper: message.whisper, - whisperMods: message.whisperMods, - whisperTo: message.whisperTo - ), - using: dependencies - ) - } - - static func processReceivedOpenGroupDirectMessage( - _ db: Database, - openGroupServerPublicKey: String, - message: OpenGroupAPI.DirectMessage, - data: Data, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - return try processRawReceivedMessage( - db, - data: data, - from: .openGroupInbox( - timestamp: message.posted, - messageServerId: message.id, - serverPublicKey: openGroupServerPublicKey, - senderId: message.sender, - recipientId: message.recipient - ), - using: dependencies - ) - } - static func processRawReceivedReactions( _ db: Database, openGroupId: String, @@ -554,25 +430,30 @@ public extension Message { associatedPendingChanges: [OpenGroupAPI.PendingChange], using dependencies: Dependencies ) -> [Reaction] { - guard let reactions: [String: OpenGroupAPI.Message.Reaction] = message.reactions else { return [] } + guard + let reactions: [String: OpenGroupAPI.Message.Reaction] = message.reactions, + let openGroupCapabilityInfo: LibSession.OpenGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: openGroupId) + else { return [] } let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId - let blinded15SessionId: SessionId? = SessionThread - .getCurrentUserBlindedSessionId( - db, + let currentUserSessionIds: Set = Set([ + currentUserSessionId, + SessionThread.getCurrentUserBlindedSessionId( threadId: openGroupId, threadVariant: .community, blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, using: dependencies - ) - let blinded25SessionId: SessionId? = SessionThread - .getCurrentUserBlindedSessionId( - db, + ), + SessionThread.getCurrentUserBlindedSessionId( threadId: openGroupId, threadVariant: .community, blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, using: dependencies ) + ].compactMap { $0 }.map { $0.hexString }) return reactions .reduce(into: []) { result, next in @@ -617,7 +498,7 @@ public extension Message { }() let shouldAddSelfReaction: Bool = ( pendingChangeSelfReaction ?? ( - (next.value.you || reactors.contains(currentUserSessionId.hexString)) && + (next.value.you || !Set(reactors).isDisjoint(with: currentUserSessionIds)) && !pendingChangeRemoveAllReaction ) ) @@ -626,11 +507,7 @@ public extension Message { let timestampMs: Int64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs() let maxLength: Int = shouldAddSelfReaction ? 4 : 5 let desiredReactorIds: [String] = reactors - .filter { id -> Bool in - id != blinded15SessionId?.hexString && - id != blinded25SessionId?.hexString && - id != currentUserSessionId.hexString - } // Remove current user for now, will add back if needed + .filter { !currentUserSessionIds.contains($0) } // Remove current user for now, will add back if needed .prefix(maxLength) .map { $0 } @@ -685,44 +562,6 @@ public extension Message { } } - private static func processRawReceivedMessage( - _ db: Database, - data: Data, - from origin: Message.Origin, - using dependencies: Dependencies - ) throws -> ProcessedMessage { - let processedMessage: ProcessedMessage = try MessageReceiver.parse( - db, - data: data, - origin: origin, - using: dependencies - ) - - switch processedMessage { - case .standard(let threadId, let threadVariant, _, let messageInfo): - // Prevent ControlMessages from being handled multiple times if not supported - do { - try ControlMessageProcessRecord( - threadId: threadId, - message: messageInfo.message, - serverExpirationTimestamp: origin.serverExpirationTimestamp - )?.insert(db) - } - catch { - // We want to custom handle this - if case DatabaseError.SQLITE_CONSTRAINT_UNIQUE = error { - throw MessageReceiverError.duplicateControlMessage - } - - throw error - } - - default: break - } - - return processedMessage - } - // MARK: - TTL for disappearing messages internal static func getSpecifiedTTL( @@ -736,7 +575,7 @@ public extension Message { switch (destination, message) { // Disappear after sent messages with exceptions case (_, is UnsendRequest): return message.ttl - + case (.closedGroup, is GroupUpdateInviteMessage), (.closedGroup, is GroupUpdateInviteResponseMessage), (.closedGroup, is GroupUpdatePromoteMessage), (.closedGroup, is GroupUpdateMemberLeftMessage), (.closedGroup, is GroupUpdateDeleteMemberContentMessage): @@ -755,6 +594,43 @@ public extension Message { } } +// MARK: - Conversion + +public extension Interaction.Variant { + /// This function can be used to create an `Interaction.Variant` from a `Message` instance + init?(message: Message, currentUserSessionIds: Set) { + switch message { + case is ReadReceipt, is TypingIndicator, is UnsendRequest, is GroupUpdatePromoteMessage, + is GroupUpdateMemberLeftMessage, is GroupUpdateInviteResponseMessage, + is GroupUpdateDeleteMemberContentMessage, is LibSessionMessage: + return nil + + case is TypingIndicator: return nil + case let message as DataExtractionNotification: + self = (message.kind == .screenshot ? + .infoScreenshotNotification : + .infoMediaSavedNotification + ) + + case is ExpirationTimerUpdate: self = .infoDisappearingMessagesUpdate + case is MessageRequestResponse: self = .infoMessageRequestAccepted + + case let message as VisibleMessage: + self = (currentUserSessionIds.contains(message.sender ?? "") ? + .standardOutgoing : + .standardIncoming + ) + + case is CallMessage: self = .infoCall + case is GroupUpdateInviteMessage: self = .infoGroupInfoInvited + case is GroupUpdateInfoChangeMessage: self = .infoGroupInfoUpdated + case is GroupUpdateMemberChangeMessage: self = .infoGroupMembersUpdated + case is GroupUpdateMemberLeftNotificationMessage: self = .infoGroupMembersUpdated + default: return nil + } + } +} + // MARK: - Mutation public extension Message { diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift index 6e898cb7c6..9e143d36e6 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage+Quote.swift @@ -8,17 +8,17 @@ public extension VisibleMessage { struct VMQuote: Codable { public let timestamp: UInt64? - public let publicKey: String? + public let authorId: String? public let text: String? public let attachmentId: String? - public func isValid(isSending: Bool) -> Bool { timestamp != nil && publicKey != nil } + public func isValid(isSending: Bool) -> Bool { timestamp != nil && authorId != nil } // MARK: - Initialization - internal init(timestamp: UInt64, publicKey: String, text: String?, attachmentId: String?) { + internal init(timestamp: UInt64, authorId: String, text: String?, attachmentId: String?) { self.timestamp = timestamp - self.publicKey = publicKey + self.authorId = authorId self.text = text self.attachmentId = attachmentId } @@ -28,7 +28,7 @@ public extension VisibleMessage { public static func fromProto(_ proto: SNProtoDataMessageQuote) -> VMQuote? { return VMQuote( timestamp: proto.id, - publicKey: proto.author, + authorId: proto.author, text: proto.text, attachmentId: nil ) @@ -39,11 +39,11 @@ public extension VisibleMessage { } public func toProto(_ db: Database) -> SNProtoDataMessageQuote? { - guard let timestamp = timestamp, let publicKey = publicKey else { + guard let timestamp = timestamp, let authorId = authorId else { Log.warn(.messageSender, "Couldn't construct quote proto from: \(self).") return nil } - let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: publicKey) + let quoteProto = SNProtoDataMessageQuote.builder(id: timestamp, author: authorId) if let text = text { quoteProto.setText(text) } addAttachmentsIfNeeded(db, to: quoteProto) do { @@ -86,7 +86,7 @@ public extension VisibleMessage { """ Quote( timestamp: \(timestamp?.description ?? "null"), - publicKey: \(publicKey ?? "null"), + authorId: \(authorId ?? "null"), text: \(text ?? "null"), attachmentId: \(attachmentId ?? "null") ) @@ -101,7 +101,7 @@ public extension VisibleMessage.VMQuote { static func from(_ db: Database, quote: Quote) -> VisibleMessage.VMQuote { return VisibleMessage.VMQuote( timestamp: UInt64(quote.timestampMs), - publicKey: quote.authorId, + authorId: quote.authorId, text: quote.body, attachmentId: quote.attachmentId ) diff --git a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift index 9be17f6bc1..516fb0a6ac 100644 --- a/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/Crypto/Crypto+OpenGroupAPI.swift @@ -4,7 +4,6 @@ import Foundation import CryptoKit -import GRDB import SessionUtil import SessionUtilitiesKit @@ -158,27 +157,22 @@ public extension Crypto.Verification { public extension Crypto.Generator { static func ciphertextWithSessionBlindingProtocol( - _ db: Database, plaintext: Data, recipientBlindedId: String, - serverPublicKey: String, - using dependencies: Dependencies + serverPublicKey: String ) -> Crypto.Generator { return Crypto.Generator( id: "ciphertextWithSessionBlindingProtocol", args: [plaintext, serverPublicKey] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cRecipientBlindedId: [UInt8] = Array(Data(hex: recipientBlindedId)) var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) var maybeCiphertext: UnsafeMutablePointer? = nil var ciphertextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, @@ -202,23 +196,17 @@ public extension Crypto.Generator { } static func plaintextWithSessionBlindingProtocol( - _ db: Database, ciphertext: Data, senderId: String, recipientId: String, - serverPublicKey: String, - using dependencies: Dependencies + serverPublicKey: String ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { return Crypto.Generator( id: "plaintextWithSessionBlindingProtocol", args: [ciphertext, senderId, recipientId] - ) { - let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { - throw MessageSenderError.noUserED25519KeyPair - }() - + ) { dependencies in var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey + var cEd25519SecretKey: [UInt8] = dependencies[cache: .general].ed25519SecretKey var cSenderId: [UInt8] = Array(Data(hex: senderId)) var cRecipientId: [UInt8] = Array(Data(hex: recipientId)) var cServerPublicKey: [UInt8] = Array(Data(hex: serverPublicKey)) @@ -226,6 +214,7 @@ public extension Crypto.Generator { var maybePlaintext: UnsafeMutablePointer? = nil var plaintextLen: Int = 0 + guard !cEd25519SecretKey.isEmpty else { throw MessageSenderError.noUserED25519KeyPair } guard cEd25519SecretKey.count == 64, cServerPublicKey.count == 32, diff --git a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift index 9b9f385139..c55bb95abe 100644 --- a/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift +++ b/SessionMessagingKit/Open Groups/Models/SOGSMessage.swift @@ -25,7 +25,7 @@ extension OpenGroupAPI { public let id: Int64 public let sender: String? - public let posted: TimeInterval? + public let posted: TimeInterval public let edited: TimeInterval? public let deleted: Bool? public let seqNo: Int64 @@ -60,10 +60,10 @@ extension OpenGroupAPI.Message { public init(from decoder: Decoder) throws { let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) - let maybeSender: String? = try? container.decode(String.self, forKey: .sender) - let maybeBase64EncodedData: String? = try? container.decode(String.self, forKey: .base64EncodedData) - let maybeBase64EncodedSignature: String? = try? container.decode(String.self, forKey: .base64EncodedSignature) - let maybeReactions: [String:Reaction]? = try? container.decode([String:Reaction].self, forKey: .reactions) + let maybeSender: String? = try container.decodeIfPresent(String.self, forKey: .sender) + let maybeBase64EncodedData: String? = try container.decodeIfPresent(String.self, forKey: .base64EncodedData) + let maybeBase64EncodedSignature: String? = try container.decodeIfPresent(String.self, forKey: .base64EncodedSignature) + let maybeReactions: [String: Reaction]? = try container.decodeIfPresent([String: Reaction].self, forKey: .reactions) // If we have data and a signature (ie. the message isn't a deletion) then validate the signature if let base64EncodedData: String = maybeBase64EncodedData, let base64EncodedSignature: String = maybeBase64EncodedSignature { @@ -104,14 +104,14 @@ extension OpenGroupAPI.Message { self = OpenGroupAPI.Message( id: try container.decode(Int64.self, forKey: .id), - sender: try? container.decode(String.self, forKey: .sender), - posted: try? container.decode(TimeInterval.self, forKey: .posted), - edited: try? container.decode(TimeInterval.self, forKey: .edited), - deleted: try? container.decode(Bool.self, forKey: .deleted), + sender: try container.decodeIfPresent(String.self, forKey: .sender), + posted: try container.decode(TimeInterval.self, forKey: .posted), + edited: try container.decodeIfPresent(TimeInterval.self, forKey: .edited), + deleted: try container.decodeIfPresent(Bool.self, forKey: .deleted), seqNo: try container.decode(Int64.self, forKey: .seqNo), - whisper: ((try? container.decode(Bool.self, forKey: .whisper)) ?? false), - whisperMods: ((try? container.decode(Bool.self, forKey: .whisperMods)) ?? false), - whisperTo: try? container.decode(String.self, forKey: .whisperTo), + whisper: ((try container.decodeIfPresent(Bool.self, forKey: .whisper)) ?? false), + whisperMods: ((try container.decodeIfPresent(Bool.self, forKey: .whisperMods)) ?? false), + whisperTo: try container.decodeIfPresent(String.self, forKey: .whisperTo), base64EncodedData: maybeBase64EncodedData, base64EncodedSignature: maybeBase64EncodedSignature, reactions: !container.contains(.reactions) ? nil : (maybeReactions ?? [:]) @@ -125,8 +125,8 @@ extension OpenGroupAPI.Message.Reaction { self = OpenGroupAPI.Message.Reaction( count: try container.decode(Int64.self, forKey: .count), - reactors: try? container.decode([String].self, forKey: .reactors), - you: (try? container.decode(Bool.self, forKey: .you)) ?? false, + reactors: try container.decodeIfPresent([String].self, forKey: .reactors), + you: ((try container.decodeIfPresent(Bool.self, forKey: .you)) ?? false), index: (try container.decode(Int64.self, forKey: .index)) ) } diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index e274a59415..43d8927c8e 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -1368,7 +1368,7 @@ public enum OpenGroupAPI { using dependencies: Dependencies ) throws -> (publicKey: String, signature: [UInt8]) { guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), + !dependencies[cache: .general].ed25519SecretKey.isEmpty, let serverPublicKey: String = try? OpenGroup .select(.publicKey) .filter(OpenGroup.Columns.server == serverName.lowercased()) @@ -1387,10 +1387,17 @@ public enum OpenGroupAPI { if forceBlinded || capabilities.isEmpty || capabilities.contains(.blind) { guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: serverPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: serverPublicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ), let signatureResult: [UInt8] = dependencies[singleton: .crypto].generate( - .signatureBlind15(message: messageBytes, serverPublicKey: serverPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .signatureBlind15( + message: messageBytes, + serverPublicKey: serverPublicKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) ) else { throw OpenGroupAPIError.signingFailed } @@ -1405,13 +1412,19 @@ public enum OpenGroupAPI { case .unblinded: guard let signature: Authentication.Signature = dependencies[singleton: .crypto].generate( - .signature(message: messageBytes, ed25519SecretKey: userEdKeyPair.secretKey) + .signature( + message: messageBytes, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey + ) + ), + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) ), case .standard(let signatureResult) = signature else { throw OpenGroupAPIError.signingFailed } return ( - publicKey: SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, + publicKey: SessionId(.unblinded, publicKey: ed25519KeyPair.publicKey).hexString, signature: signatureResult ) diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 41f2a4b2c9..230c65726f 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -330,11 +330,8 @@ public final class OpenGroupManager { .filter(id: openGroupId) .deleteAll(db) - // Remove any MessageProcessRecord entries (we will want to reprocess all OpenGroup messages - // if they get re-added) - _ = try? ControlMessageProcessRecord - .filter(ControlMessageProcessRecord.Columns.threadId == openGroupId) - .deleteAll(db) + // Remove any dedupe records (we will want to reprocess all OpenGroup messages if they get re-added) + try MessageDeduplication.deleteIfNeeded(db, threadIds: [openGroupId], using: dependencies) // Remove the open group (no foreign key to the thread so it won't auto-delete) if server?.lowercased() != OpenGroupAPI.defaultServer.lowercased() { @@ -541,22 +538,34 @@ public final class OpenGroupManager { } // Handle messages - if let base64EncodedString: String = message.base64EncodedData, - let data = Data(base64Encoded: base64EncodedString) + if + let base64EncodedString: String = message.base64EncodedData, + let data = Data(base64Encoded: base64EncodedString), + let sender: String = message.sender { do { - let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupMessage( - db, - openGroupId: openGroup.id, - openGroupServerPublicKey: openGroup.publicKey, - message: message, + let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: data, + origin: .community( + openGroupId: openGroup.id, + sender: sender, + timestamp: message.posted, + messageServerId: message.id, + whisper: message.whisper, + whisperMods: message.whisperMods, + whisperTo: message.whisperTo + ), + using: dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, using: dependencies ) switch processedMessage { - case .config, .none: break - case .standard(_, _, _, let messageInfo): + case .config, .invalid: break + case .standard(_, _, _, let messageInfo, _): try MessageReceiver.handle( db, threadId: openGroup.id, @@ -576,7 +585,6 @@ public final class OpenGroupManager { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -686,23 +694,32 @@ public final class OpenGroupManager { } do { - let processedMessage: ProcessedMessage? = try Message.processReceivedOpenGroupDirectMessage( - db, - openGroupServerPublicKey: openGroup.publicKey, - message: message, + let processedMessage: ProcessedMessage = try MessageReceiver.parse( data: messageData, + origin: .openGroupInbox( + timestamp: message.posted, + messageServerId: message.id, + serverPublicKey: openGroup.publicKey, + senderId: message.sender, + recipientId: message.recipient + ), + using: dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, using: dependencies ) switch processedMessage { - case .config, .none: break - case .standard(let threadId, _, let proto, let messageInfo): - // We want to update the BlindedIdLookup cache with the message info so we can avoid using the - // "expensive" lookup when possible + case .config, .invalid: break + case .standard(let threadId, _, let proto, let messageInfo, _): + /// We want to update the BlindedIdLookup cache with the message info so we can avoid using the + /// "expensive" lookup when possible let lookup: BlindedIdLookup = try { - // Minor optimisation to avoid processing the same sender multiple times in the same - // 'handleMessages' call (since the 'mapping' call is done within a transaction we - // will never have a mapping come through part-way through processing these messages) + /// Minor optimisation to avoid processing the same sender multiple times in the same + /// 'handleMessages' call (since the 'mapping' call is done within a transaction we + /// will never have a mapping come through part-way through processing these messages) if let result: BlindedIdLookup = lookupCache[message.recipient] { return result } @@ -759,7 +776,6 @@ public final class OpenGroupManager { case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, DatabaseError.SQLITE_CONSTRAINT, // Sometimes thrown for UNIQUE MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, MessageReceiverError.selfSend: break @@ -842,90 +858,47 @@ public final class OpenGroupManager { _ db: Database? = nil, publicKey: String, for roomToken: String?, - on server: String? + on server: String?, + currentUserSessionIds: Set ) -> Bool { guard let roomToken: String = roomToken, let server: String = server else { return false } guard let db: Database = db else { return dependencies[singleton: .storage] - .read { [weak self] db in self?.isUserModeratorOrAdmin(db, publicKey: publicKey, for: roomToken, on: server) } + .read { [weak self] db in + self?.isUserModeratorOrAdmin( + db, + publicKey: publicKey, + for: roomToken, + on: server, + currentUserSessionIds: currentUserSessionIds + ) + } .defaulting(to: false) } let groupId: String = OpenGroup.idFor(roomToken: roomToken, server: server) let targetRoles: [GroupMember.Role] = [.moderator, .admin] - let isDirectModOrAdmin: Bool = GroupMember - .filter(GroupMember.Columns.groupId == groupId) - .filter(GroupMember.Columns.profileId == publicKey) - .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db) - - // If the publicKey provided matches a mod or admin directly then just return immediately - if isDirectModOrAdmin { return true } - - // Otherwise we need to check if it's a variant of the current users key and if so we want - // to check if any of those have mod/admin entries - guard let sessionId: SessionId = try? SessionId(from: publicKey) else { return false } + var possibleKeys: Set = [publicKey] - // Conveniently the logic for these different cases works in order so we can fallthrough each - // case with only minor efficiency losses - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch sessionId.prefix { - case .standard: - guard publicKey == userSessionId.hexString else { return false } - fallthrough - - case .unblinded: - guard let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - return false - } - guard sessionId.prefix != .unblinded || publicKey == SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString else { - return false - } - fallthrough - - case .blinded15, .blinded25: - guard - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), - let openGroupPublicKey: String = try? OpenGroup - .select(.publicKey) - .filter(id: groupId) - .asRequest(of: String.self) - .fetchOne(db), - let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroupPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) - ), - let blinded25KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded25KeyPair(serverPublicKey: openGroupPublicKey, ed25519SecretKey: userEdKeyPair.secretKey) - ) - else { return false } - guard - ( - sessionId.prefix != .blinded15 && - sessionId.prefix != .blinded25 - ) || - publicKey == SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString || - publicKey == SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString - else { return false } - - // If we got to here that means that the 'publicKey' value matches one of the current - // users 'standard', 'unblinded' or 'blinded' keys and as such we should check if any - // of them exist in the `modsAndAminKeys` Set - let possibleKeys: Set = Set([ - userSessionId.hexString, - SessionId(.unblinded, publicKey: userEdKeyPair.publicKey).hexString, - SessionId(.blinded15, publicKey: blinded15KeyPair.publicKey).hexString, - SessionId(.blinded25, publicKey: blinded25KeyPair.publicKey).hexString - ]) - - return GroupMember - .filter(GroupMember.Columns.groupId == groupId) - .filter(possibleKeys.contains(GroupMember.Columns.profileId)) - .filter(targetRoles.contains(GroupMember.Columns.role)) - .isNotEmpty(db) + /// If the `publicKey` is in `currentUserSessionIds` then we want to use `currentUserSessionIds` to do + /// the lookup + if currentUserSessionIds.contains(publicKey) { + possibleKeys = currentUserSessionIds - case .group: return false + /// Add the users `unblinded` pubkey if we can get it, just for completeness + let userEdKeyPair: KeyPair? = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + if let userEdPublicKey: [UInt8] = userEdKeyPair?.publicKey { + possibleKeys.insert(SessionId(.unblinded, publicKey: userEdPublicKey).hexString) + } } + + return GroupMember + .filter(GroupMember.Columns.groupId == groupId) + .filter(possibleKeys.contains(GroupMember.Columns.profileId)) + .filter(targetRoles.contains(GroupMember.Columns.role)) + .isNotEmpty(db) } } diff --git a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift index 7aa18d9c29..af8255fbce 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/MessageReceiverError.swift @@ -6,8 +6,6 @@ import Foundation public enum MessageReceiverError: Error, CustomStringConvertible { case duplicateMessage - case duplicateMessageNewSnode - case duplicateControlMessage case invalidMessage case invalidSender case unknownMessage(SNProtoContent?) @@ -22,20 +20,19 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case decryptionFailed case noGroupKeyPair case invalidConfigMessageHandling - case requiredThreadNotInConfig case outdatedMessage case ignorableMessage + case ignorableMessageRequestMessage case duplicatedCall case missingRequiredAdminPrivileges case deprecatedMessage public var isRetryable: Bool { switch self { - case .duplicateMessage, .duplicateMessageNewSnode, .duplicateControlMessage, - .invalidMessage, .unknownMessage, .unknownEnvelopeType, .invalidSignature, - .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, - .invalidConfigMessageHandling, .requiredThreadNotInConfig, - .outdatedMessage, .ignorableMessage, .missingRequiredAdminPrivileges: + case .duplicateMessage, .invalidMessage, .unknownMessage, .unknownEnvelopeType, + .invalidSignature, .noData, .senderBlocked, .noThread, .selfSend, .decryptionFailed, + .invalidConfigMessageHandling, .outdatedMessage, .ignorableMessage, .ignorableMessageRequestMessage, + .missingRequiredAdminPrivileges: return false default: return true @@ -48,7 +45,7 @@ public enum MessageReceiverError: Error, CustomStringConvertible { // retrieving and attempting to process the same messages again (as well as ensure the // next poll doesn't retrieve the same message - these errors are essentially considered // "already successfully processed") - case .selfSend, .duplicateControlMessage, .outdatedMessage, .missingRequiredAdminPrivileges: + case .selfSend, .duplicateMessage, .outdatedMessage, .missingRequiredAdminPrivileges: return true default: return false @@ -58,8 +55,6 @@ public enum MessageReceiverError: Error, CustomStringConvertible { public var description: String { switch self { case .duplicateMessage: return "Duplicate message." - case .duplicateMessageNewSnode: return "Duplicate message from different service node." - case .duplicateControlMessage: return "Duplicate control message." case .invalidMessage: return "Invalid message." case .invalidSender: return "Invalid sender." case .unknownMessage(let content): @@ -111,9 +106,9 @@ public enum MessageReceiverError: Error, CustomStringConvertible { case .noGroupKeyPair: return "Missing group key pair." case .invalidConfigMessageHandling: return "Invalid handling of a config message." - case .requiredThreadNotInConfig: return "Required thread not in config." case .outdatedMessage: return "Message was sent before a config change which would have removed the message." case .ignorableMessage: return "Message should be ignored." + case .ignorableMessageRequestMessage: return "Message request message should be ignored." case .duplicatedCall: return "Duplicate call." case .missingRequiredAdminPrivileges: return "Handling this message requires admin privileges which the current user does not have." case .deprecatedMessage: return "This message type has been deprecated." diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift index a20c3e0cf8..9b6b133c3b 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Calls.swift @@ -71,7 +71,7 @@ extension MessageReceiver { guard let timestampMs = message.sentTimestampMs, TimestampUtils.isWithinOneMinute(timestampMs: timestampMs) else { // Add missed call message for call offer messages from more than one minute Log.info(.calls, "Got an expired call offer message with uuid: \(message.uuid). Sent at \(message.sentTimestampMs ?? 0), now is \(Date().timeIntervalSince1970 * 1000)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed, using: dependencies) { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: .missed, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -81,11 +81,27 @@ extension MessageReceiver { ) if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + message.state = .missed + + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionId: interactionId, + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant, + using: dependencies + ) + }, + shouldShowForMessageRequest: { false } ) } } @@ -97,7 +113,7 @@ extension MessageReceiver { Log.info(.calls, "Microphone permission is \(AVAudioSession.sharedInstance().recordPermission)") - if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: state, using: dependencies) { + if let interaction: Interaction = try MessageReceiver.insertCallInfoMessage(db, for: message, state: state, using: dependencies), let interactionId: Int64 = interaction.id { let thread: SessionThread = try SessionThread.upsert( db, id: sender, @@ -107,11 +123,27 @@ extension MessageReceiver { ) if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + message.state = state + + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionId: interactionId, + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant, + using: dependencies + ) + }, + shouldShowForMessageRequest: { false } ) } @@ -253,9 +285,8 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: (messageSentTimestampMs * 1000), - userSessionId: dependencies[cache: .general].sessionId, - openGroup: nil + timestampMs: messageSentTimestampMs, + openGroupUrlInfo: nil ) }, expiresInSeconds: message.expiresInSeconds, @@ -345,9 +376,8 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: thread.id, threadVariant: thread.variant, - timestampMs: (timestampMs * 1000), - userSessionId: userSessionId, - openGroup: nil + timestampMs: timestampMs, + openGroupUrlInfo: nil ) }, expiresInSeconds: message.expiresInSeconds, diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift index 62667a78f1..b902ae6043 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+DataExtractionNotification.swift @@ -32,9 +32,8 @@ extension MessageReceiver { cache.timestampAlreadyRead( threadId: threadId, threadVariant: threadVariant, - timestampMs: (timestampMs * 1000), - userSessionId: dependencies[cache: .general].sessionId, - openGroup: nil + timestampMs: timestampMs, + openGroupUrlInfo: nil ) } let messageExpirationInfo: Message.MessageExpirationInfo = Message.getMessageExpirationInfo( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift index 6cdba28ade..2a5f2ab3c0 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+Groups.swift @@ -108,11 +108,10 @@ extension MessageReceiver { ), // Somewhat redundant because we know the sender was a group admin but this confirms the // authData is valid so protects against invalid invite spam from a group admin - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db), dependencies[singleton: .crypto].verify( .memberAuthData( groupSessionId: message.groupSessionId, - ed25519SecretKey: userEd25519KeyPair.secretKey, + ed25519SecretKey: dependencies[cache: .general].ed25519SecretKey, memberAuthData: message.memberAuthData ) ) @@ -144,8 +143,8 @@ extension MessageReceiver { try processGroupInvite( db, + message: message, sender: sender, - serverHash: message.serverHash, sentTimestampMs: Int64(sentTimestampMs), groupSessionId: message.groupSessionId, groupName: message.groupName, @@ -262,8 +261,8 @@ extension MessageReceiver { // Process the promotion as a group invite (if needed) try processGroupInvite( db, + message: message, sender: sender, - serverHash: message.serverHash, sentTimestampMs: Int64(sentTimestampMs), groupSessionId: groupSessionId, groupName: message.groupName, @@ -511,7 +510,9 @@ extension MessageReceiver { guard let sender: String = message.sender, let sentTimestampMs: UInt64 = message.sentTimestampMs, - LibSession.isAdmin(groupSessionId: groupSessionId, using: dependencies) + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: groupSessionId) + }) else { throw MessageReceiverError.invalidMessage } // Trigger this removal in a separate process because it requires a number of requests to be made @@ -736,7 +737,9 @@ extension MessageReceiver { /// messages from the swarm as well guard !hashes.isEmpty, - LibSession.isAdmin(groupSessionId: groupSessionId, using: dependencies), + dependencies.mutate(cache: .libSession, { cache in + cache.isAdmin(groupSessionId: groupSessionId) + }), let authMethod: AuthenticationMethod = try? Authentication.with( db, swarmPublicKey: groupSessionId.hexString, @@ -801,13 +804,13 @@ extension MessageReceiver { /// If we haven't already handled being kicked from the group then update the name of the group in `USER_GROUPS` so /// that if the user doesn't delete the group and links a new device, the group will have the same name as on the current device - if !LibSession.wasKickedFromGroup(groupSessionId: groupSessionId, using: dependencies) { + let wasKickedFromGroup: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: groupSessionId) + } + + if !wasKickedFromGroup { dependencies.mutate(cache: .libSession) { cache in - let groupInfoConfig: LibSession.Config? = cache.config(for: .groupInfo, sessionId: groupSessionId) - let userGroupsConfig: LibSession.Config? = cache.config(for: .userGroups, sessionId: userSessionId) - let groupName: String? = try? LibSession.groupName(in: groupInfoConfig) - - switch groupName { + switch cache.groupName(groupSessionId: groupSessionId) { case .none: Log.warn(.messageReceiver, "Failed to update group name before being kicked.") case .some(let name): try? LibSession.upsert( @@ -817,7 +820,7 @@ extension MessageReceiver { name: name ) ], - in: userGroupsConfig, + in: cache.config(for: .userGroups, sessionId: userSessionId), using: dependencies ) } @@ -859,8 +862,8 @@ extension MessageReceiver { internal static func processGroupInvite( _ db: Database, + message: Message, sender: String, - serverHash: String?, sentTimestampMs: Int64, groupSessionId: SessionId, groupName: String, @@ -882,10 +885,9 @@ extension MessageReceiver { /// If we had previously been kicked from a group then we need to update the flag in `UserGroups` so that we don't consider /// ourselves as kicked anymore - let wasKickedFromGroup: Bool = LibSession.wasKickedFromGroup( - groupSessionId: groupSessionId, - using: dependencies - ) + let wasKickedFromGroup: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: groupSessionId) + } try MessageReceiver.handleNewGroup( db, groupSessionId: groupSessionId.hexString, @@ -910,7 +912,7 @@ extension MessageReceiver { /// Now that we've added the group info into the `USER_GROUPS` config we should try to delete the original invitation/promotion /// from the swarm so we don't need to worry about it being reprocessed on another device if the user happens to leave or get /// removed from the group before another device has received it (ie. stop the group from incorrectly reappearing) - switch serverHash { + switch message.serverHash { case .none: break case .some(let serverHash): db.afterNextTransaction { db in @@ -977,8 +979,7 @@ extension MessageReceiver { threadId: groupSessionId.hexString, threadVariant: .group, timestampMs: sentTimestampMs, - userSessionId: userSessionId, - openGroup: nil + openGroupUrlInfo: nil ) }, using: dependencies @@ -1007,22 +1008,37 @@ extension MessageReceiver { /// If the sender wasn't approved this is a message request so we should notify the user about the invite case (false, _): let isMainAppActive: Bool = dependencies[defaults: .appGroup, key: .isMainAppActive] - dependencies[singleton: .notificationsManager].notifyUser( + let thread: SessionThread = try SessionThread.upsert( db, - for: interaction, - in: try SessionThread.upsert( - db, - id: groupSessionId.hexString, - variant: .group, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 - ), - shouldBeVisible: .useExisting + id: groupSessionId.hexString, + variant: .group, + values: SessionThread.TargetValues( + creationDateTimestamp: .useExistingOrSetTo( + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000 ), - using: dependencies + shouldBeVisible: .useExisting ), - applicationState: (isMainAppActive ? .active : .background) + using: dependencies + ) + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionId: (interaction.id ?? 0), + interactionVariant: interaction.variant, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: nil, + applicationState: (isMainAppActive ? .active : .background), + currentUserSessionIds: [dependencies[cache: .general].sessionId.hexString], + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant, + using: dependencies + ) + }, + shouldShowForMessageRequest: { false } ) /// If the sender is approved and this was an admin invitation then do nothing diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift index 2b39844569..a68cd9e094 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+LibSession.swift @@ -18,8 +18,7 @@ extension MessageReceiver { ) throws { guard let sender: String = message.sender, - let senderSessionId: SessionId = try? SessionId(from: sender), - let userEd25519KeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + let senderSessionId: SessionId = try? SessionId(from: sender) else { throw MessageReceiverError.decryptionFailed } let supportedEncryptionDomains: [LibSession.Crypto.Domain] = [ @@ -34,7 +33,7 @@ extension MessageReceiver { .plaintextWithMultiEncrypt( ciphertext: message.ciphertext, senderSessionId: senderSessionId, - ed25519PrivateKey: userEd25519KeyPair.secretKey, + ed25519PrivateKey: dependencies[cache: .general].ed25519SecretKey, domain: domain ) ) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift index cd6f415215..ce2b2f9285 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+VisibleMessages.swift @@ -77,15 +77,15 @@ extension MessageReceiver { ), using: dependencies ) - let maybeOpenGroup: OpenGroup? = { + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo? = { guard threadVariant == .community else { return nil } - return try? OpenGroup.fetchOne(db, id: threadId) + return try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) }() let variant: Interaction.Variant = try { guard let senderSessionId: SessionId = try? SessionId(from: sender), - let openGroup: OpenGroup = maybeOpenGroup + let openGroupUrlInfo: LibSession.OpenGroupUrlInfo = openGroupUrlInfo else { return (sender == userSessionId.hexString ? .standardOutgoing : @@ -101,7 +101,7 @@ extension MessageReceiver { .sessionId( userSessionId.hexString, matchesBlindedId: sender, - serverPublicKey: openGroup.publicKey + serverPublicKey: openGroupUrlInfo.publicKey ) ) else { return .standardIncoming } @@ -119,6 +119,30 @@ extension MessageReceiver { throw MessageReceiverError.invalidSender } }() + let generateCurrentUserSessionIds: () -> Set = { + guard threadVariant == .community else { return [userSessionId.hexString] } + + let openGroupCapabilityInfo = try? LibSession.OpenGroupCapabilityInfo + .fetchOne(db, id: threadId) + + return Set([ + userSessionId, + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded15, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + ), + SessionThread.getCurrentUserBlindedSessionId( + threadId: threadId, + threadVariant: threadVariant, + blindingPrefix: .blinded25, + openGroupCapabilityInfo: openGroupCapabilityInfo, + using: dependencies + ) + ].compactMap { $0 }.map { $0.hexString }) + } // Handle emoji reacts first (otherwise it's essentially an invalid message) if let interactionId: Int64 = try handleEmojiReactIfNeeded( @@ -128,7 +152,8 @@ extension MessageReceiver { associatedWithProto: proto, sender: sender, messageSentTimestamp: messageSentTimestamp, - openGroup: maybeOpenGroup, + openGroupUrlInfo: openGroupUrlInfo, + currentUserSessionIds: generateCurrentUserSessionIds(), using: dependencies ) { return interactionId @@ -148,8 +173,7 @@ extension MessageReceiver { threadId: thread.id, threadVariant: thread.variant, timestampMs: Int64(messageSentTimestamp * 1000), - userSessionId: userSessionId, - openGroup: maybeOpenGroup + openGroupUrlInfo: openGroupUrlInfo ) } ) @@ -385,12 +409,53 @@ extension MessageReceiver { // Notify the user if needed guard variant == .standardIncoming && !interaction.wasRead else { return interactionId } - // Use the same identifier for notifications when in backgroud polling to prevent spam - dependencies[singleton: .notificationsManager].notifyUser( - db, - for: interaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: threadId, + threadVariant: threadVariant, + interactionId: interactionId, + interactionVariant: interaction.variant, + attachmentDescriptionInfo: attachments.map { $0.descriptionInfo }, + openGroupUrlInfo: openGroupUrlInfo, + applicationState: (isMainAppActive ? .active : .background), + currentUserSessionIds: generateCurrentUserSessionIds(), + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: threadVariant, + using: dependencies + ) + }, + shouldShowForMessageRequest: { + let numInteractions: Int = { + switch interaction.serverHash { + case .some(let serverHash): + return (try? Interaction + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.serverHash != serverHash) + .fetchCount(db)) + .defaulting(to: 0) + + case .none: + return (try? Interaction + .filter(Interaction.Columns.threadId == threadId) + .filter(Interaction.Columns.timestampMs != interaction.timestampMs) + .fetchCount(db)) + .defaulting(to: 0) + } + }() + + // We only want to show a notification for the first interaction in the thread + guard numInteractions == 0 else { return false } + + // Need to re-show the message requests section if it had been hidden + if db[.hasHiddenMessageRequests] { + db[.hasHiddenMessageRequests] = false + } + + return true + } ) return interactionId @@ -403,7 +468,8 @@ extension MessageReceiver { associatedWithProto proto: SNProtoContent, sender: String, messageSentTimestamp: TimeInterval, - openGroup: OpenGroup?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + currentUserSessionIds: Set, using dependencies: Dependencies ) throws -> Int64? { guard @@ -411,6 +477,8 @@ extension MessageReceiver { proto.dataMessage?.reaction != nil else { return nil } + // Since we have database access here make sure the original message for this reaction exists + // before handling it or showing a notification let maybeInteractionId: Int64? = try? Interaction .select(.id) .filter(Interaction.Columns.threadId == thread.id) @@ -452,19 +520,32 @@ extension MessageReceiver { threadId: thread.id, threadVariant: thread.variant, timestampMs: timestampMs, - userSessionId: userSessionId, - openGroup: openGroup + openGroupUrlInfo: openGroupUrlInfo ) } // Don't notify if the reaction was added before the lastest read timestamp for // the conversation if sender != userSessionId.hexString && !timestampAlreadyRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forReaction: reaction, - in: thread, - applicationState: (isMainAppActive ? .active : .background) + try? dependencies[singleton: .notificationsManager].notifyUser( + message: message, + threadId: thread.id, + threadVariant: thread.variant, + interactionId: interactionId, + interactionVariant: .standardIncoming, + attachmentDescriptionInfo: nil, + openGroupUrlInfo: openGroupUrlInfo, + applicationState: (isMainAppActive ? .active : .background), + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: { sessionId in + Profile.displayNameNoFallback( + db, + id: sessionId, + threadVariant: thread.variant, + using: dependencies + ) + }, + shouldShowForMessageRequest: { false } ) } diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d82f970694..b7649164a7 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -18,12 +18,12 @@ public enum MessageReceiver { private static var lastEncryptionKeyPairRequest: [String: Date] = [:] public static func parse( - _ db: Database, data: Data, origin: Message.Origin, using dependencies: Dependencies ) throws -> ProcessedMessage { let userSessionId: SessionId = dependencies[cache: .general].sessionId + let uniqueIdentifier: String var plaintext: Data var customProto: SNProtoContent? = nil var customMessage: Message? = nil @@ -45,10 +45,12 @@ public enum MessageReceiver { namespace: namespace, serverHash: serverHash, serverTimestampMs: serverTimestampMs, - data: data + data: data, + uniqueIdentifier: serverHash ) case (_, .community(let openGroupId, let messageSender, let timestamp, let messageServerId, let messageWhisper, let messageWhisperMods, let messageWhisperTo)): + uniqueIdentifier = "\(messageServerId)" plaintext = data.removePadding() // Remove the padding sender = messageSender sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency @@ -68,15 +70,14 @@ public enum MessageReceiver { case (_, .openGroupInbox(let timestamp, let messageServerId, let serverPublicKey, let senderId, let recipientId)): (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( .plaintextWithSessionBlindingProtocol( - db, ciphertext: data, senderId: senderId, recipientId: recipientId, - serverPublicKey: serverPublicKey, - using: dependencies + serverPublicKey: serverPublicKey ) ) + uniqueIdentifier = "\(messageServerId)" plaintext = plaintext.removePadding() // Remove the padding sentTimestampMs = UInt64(floor(timestamp * 1000)) // Convert to ms for database consistency serverHash = nil @@ -88,6 +89,9 @@ public enum MessageReceiver { threadIdGenerator = { _ in sender } case (_, .swarm(let publicKey, let namespace, let swarmServerHash, _, _)): + uniqueIdentifier = swarmServerHash + serverHash = swarmServerHash + switch namespace { case .default: guard @@ -99,15 +103,10 @@ public enum MessageReceiver { } (plaintext, sender) = try dependencies[singleton: .crypto].tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: ciphertext, - using: dependencies - ) + .plaintextWithSessionProtocol(ciphertext: ciphertext) ) plaintext = plaintext.removePadding() // Remove the padding sentTimestampMs = envelope.timestamp - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -138,7 +137,6 @@ public enum MessageReceiver { } plaintext = envelopeContent // Padding already removed for updated groups sentTimestampMs = envelope.timestamp - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -155,7 +153,6 @@ public enum MessageReceiver { customMessage = LibSessionMessage(ciphertext: data) sender = publicKey // The "group" sends these messages sentTimestampMs = 0 - serverHash = swarmServerHash openGroupServerMessageId = nil openGroupWhisper = false openGroupWhisperMods = false @@ -196,9 +193,12 @@ public enum MessageReceiver { } // Don't process the envelope any further if the sender is blocked - guard (try? Contact.fetchOne(db, id: sender))?.isBlocked != true || message.processWithBlockedSender else { - throw MessageReceiverError.senderBlocked - } + guard + !dependencies.mutate(cache: .libSession, { cache in + cache.isContactBlocked(contactId: sender) + }) || + message.processWithBlockedSender + else { throw MessageReceiverError.senderBlocked } // Ignore self sends if needed guard message.isSelfSendValid || sender != userSessionId.hexString else { @@ -221,11 +221,14 @@ public enum MessageReceiver { proto: proto, messageInfo: try MessageReceiveJob.Details.MessageInfo( message: message, - variant: try Message.Variant(from: message) ?? { throw MessageReceiverError.invalidMessage }(), + variant: try Message.Variant(from: message) ?? { + throw MessageReceiverError.invalidMessage + }(), threadVariant: threadVariant, serverExpirationTimestamp: origin.serverExpirationTimestamp, proto: proto - ) + ), + uniqueIdentifier: uniqueIdentifier ) } @@ -240,12 +243,15 @@ public enum MessageReceiver { associatedWithProto proto: SNProtoContent, using dependencies: Dependencies ) throws { - // Throw if the message is outdated and shouldn't be processed + /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config + /// has been updated since the message was sent - this should be reworked to be less edge-case prone in the future) try throwIfMessageOutdated( - db, message: message, threadId: threadId, threadVariant: threadVariant, + openGroupUrlInfo: (threadVariant != .community ? nil : + try? LibSession.OpenGroupUrlInfo.fetchOne(db, id: threadId) + ), using: dependencies ) @@ -476,92 +482,86 @@ public enum MessageReceiver { } public static func throwIfMessageOutdated( - _ db: Database, message: Message, threadId: String, threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, using dependencies: Dependencies ) throws { - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - switch message { - case is ReadReceipt: return // No visible artifact created so better to keep for more reliable read states - case is UnsendRequest: return // We should always process the removal of messages just in case - default: break + // TODO: [Database Relocation] Need the "deleted_contacts" logic to handle the 'throwIfMessageOutdated' case + // TODO: [Database Relocation] Need a way to detect _when_ the NTS conversation was hidden (so an old message won't re-show it) + switch (threadVariant, message) { + case (_, is ReadReceipt): return /// No visible artifact created so better to keep for more reliable read states + case (_, is UnsendRequest): return /// We should always process the removal of messages just in case + + /// These group update messages update the group state so should be processed even if they were old + case (.group, is GroupUpdateInviteResponseMessage): return + case (.group, is GroupUpdateDeleteMemberContentMessage): return + case (.group, is GroupUpdateMemberLeftMessage): return + + /// No special logic for these, just make sure that either the conversation is already visible, or we are allowed to + /// make a config change + case (.contact, _), (.community, _), (.legacyGroup, _): break + + /// If the destination is a group then ensure: + /// • We have credentials + /// • The group hasn't been destroyed + /// • The user wasn't kicked from the group + /// • The message wasn't sent before all messages/attachments were deleted + case (.group, _): + let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestampMs ?? 0) / 1000) + let groupSessionId: SessionId = SessionId(.group, hex: threadId) + + /// Ensure the group is able to receive messages + try dependencies.mutate(cache: .libSession) { cache in + guard + cache.hasCredentials(groupSessionId: groupSessionId), + !cache.groupIsDestroyed(groupSessionId: groupSessionId), + !cache.wasKickedFromGroup(groupSessionId: groupSessionId) + else { throw MessageReceiverError.outdatedMessage } + + return + } + + /// Ensure the message shouldn't have been deleted + try dependencies.mutate(cache: .libSession) { cache in + let deleteBefore: TimeInterval = (cache.groupDeleteBefore(groupSessionId: groupSessionId) ?? 0) + let deleteAttachmentsBefore: TimeInterval = (cache.groupDeleteAttachmentsBefore(groupSessionId: groupSessionId) ?? 0) + + guard + messageSentTimestamp > deleteBefore && ( + (message as? VisibleMessage)?.dataMessageHasAttachments == false || + messageSentTimestamp > deleteAttachmentsBefore + ) + else { throw MessageReceiverError.outdatedMessage } + + return + } } - // If the destination is a group conversation that has been destroyed then the message is outdated - guard - threadVariant != .group || - !LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies + /// If the conversation is not visible in the config and the message was sent before the last config update (minus a buffer period) + /// then we can assume that the user has hidden/deleted the conversation and it shouldn't be reshown by this (old) message + try dependencies.mutate(cache: .libSession) { cache in + let conversationInConfig: Bool? = cache.conversationInConfig( + threadId: threadId, + threadVariant: threadVariant, + visibleOnly: true, + openGroupUrlInfo: openGroupUrlInfo + ) + let canPerformConfigChange: Bool? = cache.canPerformChange( + threadId: threadId, + threadVariant: threadVariant, + changeTimestampMs: message.sentTimestampMs + .map { Int64($0) } + .defaulting(to: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()) ) - else { throw MessageReceiverError.outdatedMessage } - - // Determine if it's a group conversation that received a deletion instruction after this - // message was sent (if so then it's outdated) - let deletionInstructionSentAfterThisMessage: Bool = { - guard threadVariant == .group else { return false } - // These group update messages update the group state so should be processed even - // if they were old - switch message { - case is GroupUpdateInviteResponseMessage: return false - case is GroupUpdateDeleteMemberContentMessage: return false - case is GroupUpdateMemberLeftMessage: return false + switch (conversationInConfig, canPerformConfigChange) { + case (false, false): throw MessageReceiverError.outdatedMessage default: break } - - // Note: 'sentTimestamp' is in milliseconds so convert it - let messageSentTimestamp: TimeInterval = TimeInterval((message.sentTimestampMs ?? 0) / 1000) - let deletionInfo: (deleteBefore: TimeInterval, deleteAttachmentsBefore: TimeInterval) = dependencies.mutate(cache: .libSession) { cache in - let config: LibSession.Config? = cache.config(for: .groupInfo, sessionId: SessionId(.group, hex: threadId)) - - return ( - ((try? LibSession.groupDeleteBefore(in: config)) ?? 0), - ((try? LibSession.groupAttachmentDeleteBefore(in: config)) ?? 0) - ) - } - - return ( - deletionInfo.deleteBefore > messageSentTimestamp || ( - (message as? VisibleMessage)?.dataMessageHasAttachments == true && - deletionInfo.deleteAttachmentsBefore > messageSentTimestamp - ) - ) - }() - - guard !deletionInstructionSentAfterThisMessage else { throw MessageReceiverError.outdatedMessage } - - // If the conversation is not visible in the config and the message was sent before the last config - // update (minus a buffer period) then we can assume that the user has hidden/deleted the conversation - // and it shouldn't be reshown by this (old) message - let conversationVisibleInConfig: Bool = LibSession.conversationInConfig( - db, - threadId: threadId, - threadVariant: threadVariant, - visibleOnly: true, - using: dependencies - ) - let canPerformChange: Bool = LibSession.canPerformChange( - db, - threadId: threadId, - targetConfig: { - switch threadVariant { - case .contact: return (threadId == userSessionId.hexString ? .userProfile : .contacts) - default: return .userGroups - } - }(), - changeTimestampMs: message.sentTimestampMs - .map { Int64($0) } - .defaulting(to: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()), - using: dependencies - ) - - switch (conversationVisibleInConfig, canPerformChange) { - case (false, false): throw MessageReceiverError.outdatedMessage - default: break // Message not outdated } + + /// If we made it here then the message is not outdated } } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index 63679df8ca..8bee7a5f5b 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -204,10 +204,8 @@ public final class MessageSender { case (.contact(let publicKey), .default), (.syncMessage(let publicKey), _), (.closedGroup(let publicKey), _): let ciphertext: Data = try dependencies[singleton: .crypto].tryGenerate( .ciphertextWithSessionProtocol( - db, plaintext: plaintext, - destination: destination, - using: dependencies + destination: destination ) ) @@ -275,6 +273,9 @@ public final class MessageSender { message: updatedMessage, to: destination, interactionId: interactionId, + serverExpirationTimestamp: ( + Int64(floor(TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) / 1000)) + ), using: dependencies ) } @@ -317,7 +318,9 @@ public final class MessageSender { db, id: OpenGroup.idFor(roomToken: roomToken, server: server) ), - let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) else { throw MessageSenderError.invalidMessage } // Set the sender/recipient info (needed to be valid) @@ -337,7 +340,10 @@ public final class MessageSender { } guard let blinded15KeyPair: KeyPair = dependencies[singleton: .crypto].generate( - .blinded15KeyPair(serverPublicKey: openGroup.publicKey, ed25519SecretKey: userEdKeyPair.secretKey) + .blinded15KeyPair( + serverPublicKey: openGroup.publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ) ) else { throw MessageSenderError.signingFailed } @@ -381,7 +387,6 @@ public final class MessageSender { ) .handleEvents( receiveOutput: { _, response in - let serverTimestampMs: UInt64? = response.posted.map { UInt64(floor($0 * 1000)) } let updatedMessage: Message = message updatedMessage.openGroupServerMessageId = UInt64(response.id) @@ -392,7 +397,7 @@ public final class MessageSender { message: updatedMessage, to: destination, interactionId: interactionId, - serverTimestampMs: serverTimestampMs, + serverTimestampMs: Int64(floor(response.posted * 1000)), using: dependencies ) } @@ -471,11 +476,9 @@ public final class MessageSender { // Encrypt the serialized protobuf let ciphertext: Data = try dependencies[singleton: .crypto].generateResult( .ciphertextWithSessionBlindingProtocol( - db, plaintext: plaintext, recipientBlindedId: recipientBlindedPublicKey, - serverPublicKey: openGroupPublicKey, - using: dependencies + serverPublicKey: openGroupPublicKey ) ) .mapError { MessageSenderError.other(nil, "Couldn't encrypt message for destination: \(destination)", $0) } @@ -501,7 +504,8 @@ public final class MessageSender { message: updatedMessage, to: destination, interactionId: interactionId, - serverTimestampMs: UInt64(floor(response.posted * 1000)), + serverTimestampMs: Int64(floor(response.posted * 1000)), + serverExpirationTimestamp: Int64(floor(response.expires)), using: dependencies ) } @@ -585,7 +589,8 @@ public final class MessageSender { message: Message, to destination: Message.Destination, interactionId: Int64?, - serverTimestampMs: UInt64? = nil, + serverTimestampMs: Int64? = nil, + serverExpirationTimestamp: Int64? = nil, using dependencies: Dependencies ) throws { // If the message was a reaction then we want to update the reaction instead of the original @@ -667,15 +672,28 @@ public final class MessageSender { // Extract the threadId from the message let threadId: String = Message.threadId(forMessage: message, destination: destination, using: dependencies) - // Prevent ControlMessages from being handled multiple times if not supported - try? ControlMessageProcessRecord( + // Insert a `MessageDeduplication` record so we don't handle this message when it's received + // in the next poll + try MessageDeduplication.insert( + db, threadId: threadId, + threadVariant: destination.threadVariant, + uniqueIdentifier: { + if let serverHash: String = message.serverHash { return serverHash } + if let openGroupServerMessageId: UInt64 = message.openGroupServerMessageId { + return "\(openGroupServerMessageId)" + } + + let variantString: String = Message.Variant(from: message) + .map { "\($0)" } + .defaulting(to: "Unknown Variant") + Log.warn(.messageSender, "Unable to store deduplication unique identifier for outgoing message of type: \(variantString).") + return nil + }(), message: message, - serverExpirationTimestamp: ( - TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) + - ControlMessageProcessRecord.defaultExpirationSeconds - ) - )?.insert(db) + serverExpirationTimestamp: serverExpirationTimestamp, + using: dependencies + ) // Sync the message if needed scheduleSyncMessageIfNeeded( diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift index 0ec86569e0..b51fc94ad8 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/AuthenticatedRequest.swift @@ -45,9 +45,9 @@ extension PushNotificationAPI { try container.encode(timestamp, forKey: .timestamp) switch authMethod.info { - case .standard(let sessionId, let ed25519KeyPair): + case .standard(let sessionId, let ed25519PublicKey): try container.encode(sessionId.hexString, forKey: .pubkey) - try container.encode(ed25519KeyPair.publicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) case .groupAdmin(let sessionId, _): try container.encode(sessionId.hexString, forKey: .pubkey) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift index 212484a16f..406808c9cc 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Models/NotificationMetadata.swift @@ -65,7 +65,7 @@ extension PushNotificationAPI.NotificationMetadata { // MARK: - Convenience -extension PushNotificationAPI.NotificationMetadata { +public extension PushNotificationAPI.NotificationMetadata { static var invalid: PushNotificationAPI.NotificationMetadata { PushNotificationAPI.NotificationMetadata( accountId: "", diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift new file mode 100644 index 0000000000..96cbff61c2 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsManagerType.swift @@ -0,0 +1,400 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import Combine +import GRDB +import SessionUIKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let notificationsManager: SingletonConfig = Dependencies.create( + identifier: "notificationsManager", + createInstance: { dependencies in NoopNotificationsManager(using: dependencies) } + ) +} + +// MARK: - NotificationsManagerType + +public protocol NotificationsManagerType { + var dependencies: Dependencies { get } + + init(using dependencies: Dependencies) + + func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) + func registerNotificationSettings() -> AnyPublisher + + func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] + func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool + + func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) + func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings + ) + + func cancelNotifications(identifiers: [String]) + func clearAllNotifications() +} + +public extension NotificationsManagerType { + func ensureWeShouldShowNotification( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + interactionVariant: Interaction.Variant?, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + currentUserSessionIds: Set, + shouldShowForMessageRequest: () -> Bool, + using dependencies: Dependencies + ) throws { + guard let sender: String = message.sender else { throw MessageReceiverError.invalidSender } + + /// Don't show notifications for the `Note to Self` thread or messages sent from the current user + guard !currentUserSessionIds.contains(threadId) && !currentUserSessionIds.contains(sender) else { + throw MessageReceiverError.selfSend + } + + /// Ensure that the thread isn't muted + guard + notificationSettings.mode != .none && + dependencies.dateNow.timeIntervalSince1970 > (notificationSettings.mutedUntil ?? 0) + else { throw MessageReceiverError.ignorableMessage } + + switch message { + /// For a `VisibleMessage` we should only notify if the notification mode is `all` or if `mentionsOnly` and the + /// user was actually mentioned + case let visibleMessage as VisibleMessage: + guard interactionVariant == .standardIncoming else { throw MessageReceiverError.ignorableMessage } + guard + notificationSettings.mode == .all || ( + notificationSettings.mode == .mentionsOnly && + Interaction.isUserMentioned( + publicKeysToCheck: currentUserSessionIds, + body: visibleMessage.text, + quoteAuthorId: visibleMessage.quote?.authorId + ) + ) + else { throw MessageReceiverError.ignorableMessage } + + /// If the message is a reaction then we only want to show notifications for `contact` conversations + if visibleMessage.reaction != nil { + switch threadVariant { + case .contact: break + case .legacyGroup, .group, .community: throw MessageReceiverError.ignorableMessage + } + } + break + + /// Calls are only supported in `contact` conversations and we only want to notify for missed calls + case let callMessage as CallMessage: + guard threadVariant == .contact else { throw MessageReceiverError.invalidMessage } + guard case .preOffer = callMessage.kind else { throw MessageReceiverError.ignorableMessage } + + switch callMessage.state { + case .missed, .permissionDenied, .permissionDeniedMicrophone: break + default: throw MessageReceiverError.ignorableMessage + } + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can + /// related to the same call + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: callMessage, + using: dependencies + ) + + /// Group invitations and promotions may show notifications in some cases + case is GroupUpdateInviteMessage, is GroupUpdatePromoteMessage: break + + /// No other messages should have notifications + default: throw MessageReceiverError.ignorableMessage + } + + /// Ensure the sender isn't blocked (this should be checked when parsing the message but we should also check here in case + /// that logic ever changes) + guard + !dependencies.mutate(cache: .libSession, { cache in + cache.isContactBlocked(contactId: sender) + }) + else { throw MessageReceiverError.senderBlocked } + + /// Ensure the message hasn't already been maked as read (don't want to show notification in that case) + guard + !dependencies.mutate(cache: .libSession, { cache in + cache.timestampAlreadyRead( + threadId: threadId, + threadVariant: threadVariant, + timestampMs: (message.sentTimestampMs.map { Int64($0) } ?? 0), /// Default to unread + openGroupUrlInfo: openGroupUrlInfo + ) + }) + else { throw MessageReceiverError.ignorableMessage } + + /// If the thread is a message request then we only want to show a notification for the first message + switch (threadVariant, isMessageRequest) { + case (.community, _), (.legacyGroup, _), (.contact, false), (.group, false): break + case (.contact, true), (.group, true): + guard shouldShowForMessageRequest() else { throw MessageReceiverError.ignorableMessageRequestMessage } + break + } + + /// If we made it here then we should show the notification + } + + func notificationTitle( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + displayNameRetriever: (String) -> String?, + using dependencies: Dependencies + ) throws -> String { + switch (notificationSettings.previewType, message.sender, isMessageRequest, threadVariant) { + /// If it's a message request or shouldn't have a title then use something generic + case (.noNameNoPreview, _, _, _), (_, _, true, _), (_, .none, _, _): + return Constants.app_name + + case (.nameNoPreview, .some(let sender), _, .contact), (.nameAndPreview, .some(let sender), _, .contact): + return displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + case (.nameNoPreview, .some(let sender), _, .group), (.nameAndPreview, .some(let sender), _, .group): + let groupId: SessionId = SessionId(.group, hex: threadId) + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + let groupName: String = dependencies.mutate(cache: .libSession) { cache in + cache.groupName(groupSessionId: groupId) + } + .defaulting(to: "groupUnknown".localized()) + + return "notificationsIosGroup" + .put(key: "name", value: senderName) + .put(key: "conversation_name", value: groupName) + .localized() + + default: throw MessageReceiverError.ignorableMessage + } + } + + func notificationBody( + message: Message, + threadVariant: SessionThread.Variant, + isMessageRequest: Bool, + notificationSettings: Preferences.NotificationSettings, + interactionVariant: Interaction.Variant?, + attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String?, + using dependencies: Dependencies + ) -> String { + /// If it's a message request then use something generic + guard !isMessageRequest else { return "messageRequestsNew".localized() } + + /// If it shouldn't have the content or has no sender then use something generic + guard + let sender: String = message.sender, + notificationSettings.previewType == .nameAndPreview + else { + return "messageNewYouveGot" + .putNumber(1) + .localized() + } + + switch message { + case let visibleMessage as VisibleMessage where visibleMessage.reaction != nil: + return "emojiReactsNotification" + .put(key: "emoji", value: (visibleMessage.reaction?.emoji ?? "")) + .localized() + + case let visibleMessage as VisibleMessage: + return (interactionVariant + .map { variant -> String in + Interaction.previewText( + variant: variant, + body: visibleMessage.text, + authorDisplayName: displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)), + attachmentDescriptionInfo: attachmentDescriptionInfo?.first, + attachmentCount: (attachmentDescriptionInfo?.count ?? 0), + isOpenGroupInvitation: (visibleMessage.openGroupInvitation != nil), + using: dependencies + ) + }? + .filteredForDisplay + .filteredForNotification + .nullIfEmpty? + .replacingMentions( + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + )) + .defaulting(to: "messageNewYouveGot" + .putNumber(1) + .localized() + ) + + case let callMessage as CallMessage where callMessage.state == .permissionDenied: + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + return "callsYouMissedCallPermissions" + .put(key: "name", value: senderName) + .localizedDeformatted() + + case is CallMessage: + let senderName: String = displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + + return "callsMissedCallFrom" + .put(key: "name", value: senderName) + .localizedDeformatted() + + /// Fallback to soemthing generic + default: + return "messageNewYouveGot" + .putNumber(1) + .localized() + } + } + + func notifyUser( + message: Message, + threadId: String, + threadVariant: SessionThread.Variant, + interactionId: Int64, + interactionVariant: Interaction.Variant?, + attachmentDescriptionInfo: [Attachment.DescriptionInfo]?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo?, + applicationState: UIApplication.State, + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String?, + shouldShowForMessageRequest: () -> Bool + ) throws { + let targetConfig: ConfigDump.Variant = (threadVariant == .contact ? .contacts : .userGroups) + let isMessageRequest: Bool = dependencies.mutate(cache: .libSession) { cache in + cache.isMessageRequest( + threadId: threadId, + threadVariant: threadVariant + ) + } + let notificationSettings: Preferences.NotificationSettings = dependencies.mutate(cache: .libSession) { cache in + cache.notificationSettings( + threadId: threadId, + threadVariant: threadVariant, + openGroupUrlInfo: openGroupUrlInfo + ) + } + + /// Ensure we should be showing a notification for the thread + try ensureWeShouldShowNotification( + message: message, + threadId: threadId, + threadVariant: threadVariant, + interactionVariant: interactionVariant, + isMessageRequest: isMessageRequest, + notificationSettings: notificationSettings, + openGroupUrlInfo: openGroupUrlInfo, + currentUserSessionIds: currentUserSessionIds, + shouldShowForMessageRequest: { + !dependencies[singleton: .extensionHelper] + .hasAtLeastOneDedupeRecord(threadId: threadId) + }, + using: dependencies + ) + + /// Actually add the notification + addNotificationRequest( + content: NotificationContent( + threadId: threadId, + threadVariant: threadVariant, + identifier: { + switch (message as? VisibleMessage)?.reaction { + case .some: return UUID().uuidString + default: + return Interaction.notificationIdentifier( + for: interactionId, + threadId: threadId, + shouldGroupMessagesForThread: (threadVariant == .community) + ) + } + }(), + category: .incomingMessage, + title: try notificationTitle( + message: message, + threadId: threadId, + threadVariant: threadVariant, + isMessageRequest: isMessageRequest, + notificationSettings: notificationSettings, + displayNameRetriever: displayNameRetriever, + using: dependencies + ), + body: notificationBody( + message: message, + threadVariant: threadVariant, + isMessageRequest: isMessageRequest, + notificationSettings: notificationSettings, + interactionVariant: interactionVariant, + attachmentDescriptionInfo: attachmentDescriptionInfo, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever, + using: dependencies + ), + // TODO: [Database Relocation] Need to figure out how to manage the unread count... + /// Update the app badge in case the unread count changed + // if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { + // notificationContent.badge = NSNumber(value: unreadCount) + // } + // badge: , + sound: notificationSettings.sound, + userInfo: notificationUserInfo(threadId: threadId, threadVariant: threadVariant), + applicationState: applicationState + ), + notificationSettings: notificationSettings + ) + } +} + +// MARK: - NoopNotificationsManager + +public struct NoopNotificationsManager: NotificationsManagerType { + public let dependencies: Dependencies + + public init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {} + + public func registerNotificationSettings() -> AnyPublisher { + return Just(()).eraseToAnyPublisher() + } + + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [:] + } + + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return false + } + + public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) {} + + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings + ) {} + public func cancelNotifications(identifiers: [String]) {} + public func clearAllNotifications() {} +} + +// MARK: - Notifications + +public enum Notifications { + /// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid + /// firing too many notifications at the same time + public static let delayForGroupedNotifications: TimeInterval = 5 +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift b/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift deleted file mode 100644 index 43e1b572c7..0000000000 --- a/SessionMessagingKit/Sending & Receiving/Notifications/NotificationsProtocol.swift +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import Combine -import GRDB -import SessionUtilitiesKit - -// MARK: - Singleton - -public extension Singleton { - static let notificationsManager: SingletonConfig = Dependencies.create( - identifier: "notificationsManager", - createInstance: { dependencies in NoopNotificationsManager(using: dependencies) } - ) -} - -// MARK: - NotificationsManagerType - -public protocol NotificationsManagerType { - init(using dependencies: Dependencies) - - func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) - func registerNotificationSettings() -> AnyPublisher - - func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) - - func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) - func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) - func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) - - func cancelNotifications(identifiers: [String]) - func clearAllNotifications() -} - -// MARK: - NoopNotificationsManager - -public struct NoopNotificationsManager: NotificationsManagerType { - public init(using dependencies: Dependencies) {} - - public func setDelegate(_ delegate: (any UNUserNotificationCenterDelegate)?) {} - - public func registerNotificationSettings() -> AnyPublisher { - return Just(()).eraseToAnyPublisher() - } - - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) {} - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) {} - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) {} - public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) {} - - public func cancelNotifications(identifiers: [String]) {} - public func clearAllNotifications() {} -} - -// MARK: - Notifications - -public enum Notifications { - /// Delay notification of incoming messages when we want to group them (eg. during background polling) to avoid - /// firing too many notifications at the same time - public static let delayForGroupedNotifications: TimeInterval = 5 -} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 8c949371f2..845e668592 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -134,9 +134,15 @@ public enum PushNotificationAPI { throw NetworkError.invalidPreparedRequest } - guard let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies) else { + guard let notificationsEncryptionKey: Data = try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .pushNotificationEncryptionKey, + length: encryptionKeyLength, + cat: .cat, + legacyKey: "PNEncryptionKeyKey", + legacyService: "PNKeyChainService" + ) else { Log.error(.cat, "Unable to retrieve PN encryption key.") - throw StorageError.invalidKeySpec + throw KeychainStorageError.keySpecInvalid } return try Network.PreparedRequest( @@ -191,7 +197,7 @@ public enum PushNotificationAPI { receiveCompletion: { result in switch result { case .finished: break - case .failure: Log.error(.cat, "Couldn't subscribe for push notifications.") + case .failure(let error): Log.error(.cat, "Couldn't subscribe for push notifications due to error: \(error).") } } ) @@ -238,7 +244,7 @@ public enum PushNotificationAPI { receiveCompletion: { result in switch result { case .finished: break - case .failure: Log.error(.cat, "Couldn't unsubscribe for push notifications.") + case .failure(let error): Log.error(.cat, "Couldn't unsubscribe for push notifications due to error: \(error).") } } ) @@ -262,7 +268,13 @@ public enum PushNotificationAPI { // Decrypt and decode the payload guard let encryptedData: Data = Data(base64Encoded: base64EncodedEncString), - let notificationsEncryptionKey: Data = try? getOrGenerateEncryptionKey(using: dependencies), + let notificationsEncryptionKey: Data = try? dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .pushNotificationEncryptionKey, + length: encryptionKeyLength, + cat: .cat, + legacyKey: "PNEncryptionKeyKey", + legacyService: "PNKeyChainService" + ), let decryptedData: Data = dependencies[singleton: .crypto].generate( .plaintextWithPushNotificationPayload( payload: encryptedData, @@ -293,54 +305,4 @@ public enum PushNotificationAPI { // Success, we have the notification content return (notificationData, notification.info, .success) } - - // MARK: - Security - - @discardableResult private static func getOrGenerateEncryptionKey(using dependencies: Dependencies) throws -> Data { - do { - try dependencies[singleton: .keychain].migrateLegacyKeyIfNeeded( - legacyKey: "PNEncryptionKeyKey", - legacyService: "PNKeyChainService", - toKey: .pushNotificationEncryptionKey - ) - var encryptionKey: Data = try dependencies[singleton: .keychain].data(forKey: .pushNotificationEncryptionKey) - defer { encryptionKey.resetBytes(in: 0.. NotificationContent { + return NotificationContent( + threadId: threadId, + threadVariant: threadVariant, + identifier: identifier, + category: category, + title: (title ?? self.title), + body: (body ?? self.body), + badge: (badge ?? self.badge), + sound: (sound ?? self.sound), + userInfo: userInfo, + applicationState: applicationState + ) + } + + public func toMutableContent(shouldPlaySound: Bool) -> UNMutableNotificationContent { + let content: UNMutableNotificationContent = UNMutableNotificationContent() + content.threadIdentifier = threadId + content.categoryIdentifier = category.identifier + content.userInfo = userInfo + + if let title: String = title { content.title = title } + if let body: String = body { content.body = body } + if let badge: Int = badge { content.badge = NSNumber(integerLiteral: badge) } + + if shouldPlaySound { + content.sound = sound.notificationSound(isQuiet: (applicationState == .active)) + } + + return content + } +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift new file mode 100644 index 0000000000..9df3f253e1 --- /dev/null +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/NotificationUserInfoKey.swift @@ -0,0 +1,14 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. +// +// stringlint:disable + +import Foundation + +public struct NotificationUserInfoKey { + public static let isFromRemote = "remote" + public static let threadId = "Signal.AppNotificationsUserInfoKey.threadId" + public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" + public static let callBackNumber = "Signal.AppNotificationsUserInfoKey.callBackNumber" + public static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" + public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" +} diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift index f059dc2ee5..07496de265 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/Types/ProcessResult.swift @@ -8,8 +8,6 @@ public extension PushNotificationAPI { case successTooLong case failure case failureNoContent - case legacySuccess case legacyFailure - case legacyForceSilent } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift index d91a79cf1f..ce21f342f8 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/GroupPoller.swift @@ -106,7 +106,7 @@ public final class GroupPoller: SwarmPoller { cache.conversationLastRead( threadId: pollerDestination.target, threadVariant: .group, - openGroup: nil + openGroupUrlInfo: nil ) } .map { lastReadTimestampMs in diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift index 04eb19cfd9..6799fbb82a 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/SwarmPoller.swift @@ -128,243 +128,12 @@ public class SwarmPoller: SwarmPollerType & PollerType { request.send(using: dependencies) .map { _, response in (snode, response) } } - .flatMap { [pollerDestination, shouldStoreMessages, dependencies] (snode: LibSession.Snode, namespacedResults: SnodeAPI.PollResponse) -> AnyPublisher<(configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult), Error> in - // Get all of the messages and sort them by their required 'processingOrder' - let sortedMessages: [(namespace: SnodeAPI.Namespace, messages: [SnodeReceivedMessage])] = namespacedResults - .compactMap { namespace, result in (result.data?.messages).map { (namespace, $0) } } - .sorted { lhs, rhs in lhs.namespace.processingOrder < rhs.namespace.processingOrder } - let rawMessageCount: Int = sortedMessages.map { $0.messages.count }.reduce(0, +) - - // No need to do anything if there are no messages - guard rawMessageCount > 0 else { - return Just(([], [], ([], 0, 0, false))) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } - - // Otherwise process the messages and add them to the queue for handling - let lastHashes: [String] = namespacedResults - .compactMap { $0.value.data?.lastHash } - let otherKnownHashes: [String] = namespacedResults - .filter { $0.key.shouldFetchSinceLastHash } - .compactMap { $0.value.data?.messages.map { $0.info.hash } } - .reduce([], +) - var messageCount: Int = 0 - var finalProcessedMessages: [ProcessedMessage] = [] - var hadValidHashUpdate: Bool = false - - return dependencies[singleton: .storage].writePublisher { db -> (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) in - // If the poll was successful we need to retrieve the `lastHash` values - // direct from the database again to ensure they still line up (if they - // have been reset in the database then we want to ignore the poll as it - // would invalidate whatever change modified the `lastHash` values potentially - // resulting in us not polling again from scratch even if we want to) - let lastHashesAfterFetch: Set = try Set(namespacedResults - .compactMap { namespace, _ in - try SnodeReceivedMessageInfo - .fetchLastNotExpired( - db, - for: snode, - namespace: namespace, - swarmPublicKey: pollerDestination.target, - using: dependencies - )? - .hash - }) - - guard lastHashes.isEmpty || Set(lastHashes) == lastHashesAfterFetch else { - return ([], [], ([], 0, 0, false)) - } - - // Since the hashes are still accurate we can now process the messages - let allProcessedMessages: [ProcessedMessage] = sortedMessages - .compactMap { namespace, messages -> [ProcessedMessage]? in - let processedMessages: [ProcessedMessage] = messages - .compactMap { message -> ProcessedMessage? in - do { - return try Message.processRawReceivedMessage( - db, - rawMessage: message, - swarmPublicKey: pollerDestination.target, - shouldStoreMessages: shouldStoreMessages, - using: dependencies - ) - } - catch { - switch error { - /// Ignore duplicate & selfSend message errors (and don't bother logging them as there - /// will be a lot since we each service node duplicates messages) - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, - DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE - MessageReceiverError.duplicateMessage, - MessageReceiverError.duplicateControlMessage, - MessageReceiverError.selfSend: - break - - case MessageReceiverError.duplicateMessageNewSnode: - hadValidHashUpdate = true - break - - case DatabaseError.SQLITE_ABORT: - Log.warn(.poller, "Failed to the database being suspended (running in background with no background task).") - - default: Log.error(.poller, "Failed to deserialize envelope due to error: \(error).") - } - - return nil - } - } - - /// If this message should be handled by this poller and should be handled synchronously then do so here before - /// processing the next namespace - guard shouldStoreMessages && namespace.shouldHandleSynchronously else { - return processedMessages - } - - if namespace.isConfigNamespace { - do { - /// Process config messages all at once in case they are multi-part messages - try dependencies.mutate(cache: .libSession) { - try $0.handleConfigMessages( - db, - swarmPublicKey: pollerDestination.target, - messages: ConfigMessageReceiveJob - .Details(messages: processedMessages) - .messages - ) - } - } - catch { Log.error(.poller, "Failed to handle processed config message due to error: \(error).") } - } - else { - /// Individually process non-config messages - processedMessages.forEach { processedMessage in - guard case .standard(let threadId, let threadVariant, let proto, let messageInfo) = processedMessage else { - return - } - - do { - try MessageReceiver.handle( - db, - threadId: threadId, - threadVariant: threadVariant, - message: messageInfo.message, - serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, - associatedWithProto: proto, - using: dependencies - ) - } - catch { Log.error(.poller, "Failed to handle processed message due to error: \(error).") } - } - } - - /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` - /// as otherwise they wouldn't be emitted by the `receivedPollResponseSubject` - finalProcessedMessages += processedMessages - return nil - } - .flatMap { $0 } - - // If we don't want to store the messages then no need to continue (don't want - // to create message receive jobs or mess with cached hashes) - guard shouldStoreMessages else { - messageCount += allProcessedMessages.count - finalProcessedMessages += allProcessedMessages - return ([], [], (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) - } - - // Add a job to process the config messages first - let configMessageJobs: [Job] = allProcessedMessages - .filter { $0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } - .grouped { $0.threadId } - .compactMap { threadId, threadMessages in - messageCount += threadMessages.count - finalProcessedMessages += threadMessages - - let job: Job? = Job( - variant: .configMessageReceive, - behaviour: .runOnce, - threadId: threadId, - details: ConfigMessageReceiveJob.Details(messages: threadMessages) - ) - - // If we are force-polling then add to the JobRunner so they are - // persistent and will retry on the next app run if they fail but - // don't let them auto-start - return dependencies[singleton: .jobRunner].add( - db, - job: job, - canStartJob: ( - !forceSynchronousProcessing && - !dependencies[singleton: .appContext].isInBackground - ) - ) - } - let configJobIds: [Int64] = configMessageJobs.compactMap { $0.id } - - // Add jobs for processing non-config messages which are dependant on the config message - // processing jobs - let standardMessageJobs: [Job] = allProcessedMessages - .filter { !$0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } - .grouped { $0.threadId } - .compactMap { threadId, threadMessages in - messageCount += threadMessages.count - finalProcessedMessages += threadMessages - - let job: Job? = Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: threadId, - details: MessageReceiveJob.Details(messages: threadMessages) - ) - - // If we are force-polling then add to the JobRunner so they are - // persistent and will retry on the next app run if they fail but - // don't let them auto-start - let updatedJob: Job? = dependencies[singleton: .jobRunner].add( - db, - job: job, - canStartJob: ( - !forceSynchronousProcessing && ( - !dependencies[singleton: .appContext].isInBackground || - // FIXME: Better seperate the call messages handling, since we need to handle them all the time - dependencies[singleton: .callManager].currentCall != nil - ) - ) - ) - - // Create the dependency between the jobs (config processing should happen before - // standard message processing) - if let updatedJobId: Int64 = updatedJob?.id { - do { - try configJobIds.forEach { configJobId in - try JobDependencies( - jobId: updatedJobId, - dependantId: configJobId - ) - .insert(db) - } - } - catch { - Log.warn(.poller, "Failed to add dependency between config processing and non-config processing messageReceive jobs.") - } - } - - return updatedJob - } - - // Update the cached validity of the messages - try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( - db, - potentiallyInvalidHashes: (sortedMessages.isEmpty && !hadValidHashUpdate ? - lastHashes : - [] - ), - otherKnownValidHashes: otherKnownHashes - ) - - return (configMessageJobs, standardMessageJobs, (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) - } + .flatMapOptional { [weak self] (snode: LibSession.Snode, namespacedResults: SnodeAPI.PollResponse) -> AnyPublisher<(configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult), Error>? in + self?.processPollResponse( + forceSynchronousProcessing: forceSynchronousProcessing, + snode: snode, + namespacedResults: namespacedResults + ) } .flatMap { [dependencies] (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) -> AnyPublisher in // If we don't want to forcible process the response synchronously then just finish immediately @@ -428,4 +197,280 @@ public class SwarmPoller: SwarmPollerType & PollerType { ) .eraseToAnyPublisher() } + + private func processPollResponse( + forceSynchronousProcessing: Bool, + snode: LibSession.Snode, + namespacedResults: SnodeAPI.PollResponse + ) -> AnyPublisher<(configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult), Error> { + // Get all of the messages and sort them by their required 'processingOrder' + let sortedMessages: [(namespace: SnodeAPI.Namespace, messages: [SnodeReceivedMessage])] = namespacedResults + .compactMap { namespace, result in (result.data?.messages).map { (namespace, $0) } } + .sorted { lhs, rhs in lhs.namespace.processingOrder < rhs.namespace.processingOrder } + let rawMessageCount: Int = sortedMessages.map { $0.messages.count }.reduce(0, +) + + // No need to do anything if there are no messages + guard rawMessageCount > 0 else { + return Just(([], [], ([], 0, 0, false))) + .setFailureType(to: Error.self) + .eraseToAnyPublisher() + } + + // Otherwise process the messages and add them to the queue for handling + let lastHashes: [String] = namespacedResults + .compactMap { $0.value.data?.lastHash } + let otherKnownHashes: [String] = namespacedResults + .filter { $0.key.shouldFetchSinceLastHash } + .compactMap { $0.value.data?.messages.map { $0.info.hash } } + .reduce([], +) + var messageCount: Int = 0 + var finalProcessedMessages: [ProcessedMessage] = [] + var hadValidHashUpdate: Bool = false + + return dependencies[singleton: .storage].writePublisher { [pollerDestination, shouldStoreMessages, dependencies] db -> (configMessageJobs: [Job], standardMessageJobs: [Job], pollResult: PollResult) in + // If the poll was successful we need to retrieve the `lastHash` values + // direct from the database again to ensure they still line up (if they + // have been reset in the database then we want to ignore the poll as it + // would invalidate whatever change modified the `lastHash` values potentially + // resulting in us not polling again from scratch even if we want to) + let lastHashesAfterFetch: Set = try Set(namespacedResults + .compactMap { namespace, _ in + try SnodeReceivedMessageInfo + .fetchLastNotExpired( + db, + for: snode, + namespace: namespace, + swarmPublicKey: pollerDestination.target, + using: dependencies + )? + .hash + }) + + guard lastHashes.isEmpty || Set(lastHashes) == lastHashesAfterFetch else { + return ([], [], ([], 0, 0, false)) + } + + // Since the hashes are still accurate we can now process the messages + let allProcessedMessages: [ProcessedMessage] = sortedMessages + .compactMap { namespace, messages -> [ProcessedMessage]? in + let processedMessages: [ProcessedMessage] = messages + .compactMap { message -> ProcessedMessage? in + do { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: message.data, + origin: .swarm( + publicKey: pollerDestination.target, + namespace: message.namespace, + serverHash: message.info.hash, + serverTimestampMs: message.timestampMs, + serverExpirationTimestamp: TimeInterval(Double(message.info.expirationDateMs) / 1000) + ), + using: dependencies + ) + try MessageDeduplication.insert( + db, + processedMessage: processedMessage, + using: dependencies + ) + hadValidHashUpdate = message.info.storeUpdatedLastHash(db) + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can + /// related to the same call + switch processedMessage { + case .standard(let threadId, _, _, let messageInfo, _): + guard let callMessage: CallMessage = messageInfo.message as? CallMessage else { + break + } + + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: callMessage, + using: dependencies + ) + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.uuid + ) + + default: break + } + + return processedMessage + } + catch { + // For some error cases we want to update the last hash so do so + if (error as? MessageReceiverError)?.shouldUpdateLastHash == true { + hadValidHashUpdate = message.info.storeUpdatedLastHash(db) + } + + switch error { + /// Ignore duplicate & selfSend message errors (and don't bother logging them as there + /// will be a lot since we each service node duplicates messages) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE, + DatabaseError.SQLITE_CONSTRAINT, /// Sometimes thrown for UNIQUE + MessageReceiverError.duplicateMessage, + MessageReceiverError.selfSend: + break + + case DatabaseError.SQLITE_ABORT: + Log.warn(.poller, "Failed to the database being suspended (running in background with no background task).") + + default: Log.error(.poller, "Failed to deserialize envelope due to error: \(error).") + } + + return nil + } + } + + /// If this message should be handled by this poller and should be handled synchronously then do so here before + /// processing the next namespace + guard shouldStoreMessages && namespace.shouldHandleSynchronously else { + return processedMessages + } + + if namespace.isConfigNamespace { + do { + /// Process config messages all at once in case they are multi-part messages + try dependencies.mutate(cache: .libSession) { + try $0.handleConfigMessages( + db, + swarmPublicKey: pollerDestination.target, + messages: ConfigMessageReceiveJob + .Details(messages: processedMessages) + .messages + ) + } + } + catch { Log.error(.poller, "Failed to handle processed config message due to error: \(error).") } + } + else { + /// Individually process non-config messages + processedMessages.forEach { processedMessage in + guard case .standard(let threadId, let threadVariant, let proto, let messageInfo, _) = processedMessage else { + return + } + + do { + try MessageReceiver.handle( + db, + threadId: threadId, + threadVariant: threadVariant, + message: messageInfo.message, + serverExpirationTimestamp: messageInfo.serverExpirationTimestamp, + associatedWithProto: proto, + using: dependencies + ) + } + catch { Log.error(.poller, "Failed to handle processed message due to error: \(error).") } + } + } + + /// Make sure to add any synchronously processed messages to the `finalProcessedMessages` + /// as otherwise they wouldn't be emitted by the `receivedPollResponseSubject` + finalProcessedMessages += processedMessages + return nil + } + .flatMap { $0 } + + // If we don't want to store the messages then no need to continue (don't want + // to create message receive jobs or mess with cached hashes) + guard shouldStoreMessages else { + messageCount += allProcessedMessages.count + finalProcessedMessages += allProcessedMessages + return ([], [], (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + } + + // Add a job to process the config messages first + let configMessageJobs: [Job] = allProcessedMessages + .filter { $0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } + .grouped { $0.threadId } + .compactMap { threadId, threadMessages in + messageCount += threadMessages.count + finalProcessedMessages += threadMessages + + let job: Job? = Job( + variant: .configMessageReceive, + behaviour: .runOnce, + threadId: threadId, + details: ConfigMessageReceiveJob.Details(messages: threadMessages) + ) + + // If we are force-polling then add to the JobRunner so they are + // persistent and will retry on the next app run if they fail but + // don't let them auto-start + return dependencies[singleton: .jobRunner].add( + db, + job: job, + canStartJob: ( + !forceSynchronousProcessing && + !dependencies[singleton: .appContext].isInBackground + ) + ) + } + let configJobIds: [Int64] = configMessageJobs.compactMap { $0.id } + + // Add jobs for processing non-config messages which are dependant on the config message + // processing jobs + let standardMessageJobs: [Job] = allProcessedMessages + .filter { !$0.isConfigMessage && !$0.namespace.shouldHandleSynchronously } + .grouped { $0.threadId } + .compactMap { threadId, threadMessages in + messageCount += threadMessages.count + finalProcessedMessages += threadMessages + + let job: Job? = Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: threadId, + details: MessageReceiveJob.Details(messages: threadMessages) + ) + + // If we are force-polling then add to the JobRunner so they are + // persistent and will retry on the next app run if they fail but + // don't let them auto-start + let updatedJob: Job? = dependencies[singleton: .jobRunner].add( + db, + job: job, + canStartJob: ( + !forceSynchronousProcessing && ( + !dependencies[singleton: .appContext].isInBackground || + // FIXME: Better seperate the call messages handling, since we need to handle them all the time + dependencies[singleton: .callManager].currentCall != nil + ) + ) + ) + + // Create the dependency between the jobs (config processing should happen before + // standard message processing) + if let updatedJobId: Int64 = updatedJob?.id { + do { + try configJobIds.forEach { configJobId in + try JobDependencies( + jobId: updatedJobId, + dependantId: configJobId + ) + .insert(db) + } + } + catch { + Log.warn(.poller, "Failed to add dependency between config processing and non-config processing messageReceive jobs.") + } + } + + return updatedJob + } + + // Update the cached validity of the messages + try SnodeReceivedMessageInfo.handlePotentialDeletedOrInvalidHash( + db, + potentiallyInvalidHashes: (sortedMessages.isEmpty && !hadValidHashUpdate ? + lastHashes : + [] + ), + otherKnownValidHashes: otherKnownHashes + ) + + return (configMessageJobs, standardMessageJobs, (finalProcessedMessages, rawMessageCount, messageCount, hadValidHashUpdate)) + } + } } diff --git a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift index 6401e7d82f..b7b8b8adfc 100644 --- a/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift +++ b/SessionMessagingKit/Sending & Receiving/Quotes/QuotedReplyModel.swift @@ -12,9 +12,7 @@ public struct QuotedReplyModel { public let contentType: String? public let sourceFileName: String? public let thumbnailDownloadFailed: Bool - public let currentUserSessionId: String? - public let currentUserBlinded15SessionId: String? - public let currentUserBlinded25SessionId: String? + public let currentUserSessionIds: Set // MARK: - Initialization @@ -27,9 +25,7 @@ public struct QuotedReplyModel { contentType: String?, sourceFileName: String?, thumbnailDownloadFailed: Bool, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) { self.attachment = attachment self.threadId = threadId @@ -39,9 +35,7 @@ public struct QuotedReplyModel { self.contentType = contentType self.sourceFileName = sourceFileName self.thumbnailDownloadFailed = thumbnailDownloadFailed - self.currentUserSessionId = currentUserSessionId - self.currentUserBlinded15SessionId = currentUserBlinded15SessionId - self.currentUserBlinded25SessionId = currentUserBlinded25SessionId + self.currentUserSessionIds = currentUserSessionIds } public static func quotedReplyForSending( @@ -52,9 +46,7 @@ public struct QuotedReplyModel { timestampMs: Int64, attachments: [Attachment]?, linkPreviewAttachment: Attachment?, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String? + currentUserSessionIds: Set ) -> QuotedReplyModel? { guard variant == .standardOutgoing || variant == .standardIncoming else { return nil } guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil } @@ -70,9 +62,7 @@ public struct QuotedReplyModel { contentType: targetAttachment?.contentType, sourceFileName: targetAttachment?.sourceFilename, thumbnailDownloadFailed: false, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId + currentUserSessionIds: currentUserSessionIds ) } } diff --git a/SessionMessagingKit/Shared Models/MentionInfo.swift b/SessionMessagingKit/Shared Models/MentionInfo.swift index 49525e4659..3e78fb6ba8 100644 --- a/SessionMessagingKit/Shared Models/MentionInfo.swift +++ b/SessionMessagingKit/Shared Models/MentionInfo.swift @@ -23,12 +23,10 @@ public struct MentionInfo: FetchableRecord, Decodable, ColumnExpressible { public extension MentionInfo { // stringlint:ignore_contents static func query( - userPublicKey: String, threadId: String, threadVariant: SessionThread.Variant, targetPrefixes: [SessionId.Prefix], - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, pattern: FTS5Pattern? ) -> AdaptedFetchRequest>? { let profile: TypedTableAlias = TypedTableAlias() @@ -47,11 +45,6 @@ public extension MentionInfo { } .joined(operator: .or) let profileFullTextSearch: SQL = SQL(stringLiteral: Profile.fullTextSearchTableName) - let currentUserIds: Set = [ - userPublicKey, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ].compactMap { $0 }.asSet() /// The query needs to differ depending on the thread variant because the behaviour should be different: /// @@ -98,9 +91,9 @@ public extension MentionInfo { \(targetJoin) \(targetWhere) AND ( \(SQL("\(profile[.id]) = \(threadId)")) OR - \(SQL("\(profile[.id]) IN \(currentUserIds)")) + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) ) - ORDER BY \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC + ORDER BY \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC """) case .legacyGroup, .group: @@ -118,7 +111,7 @@ public extension MentionInfo { \(targetWhere) GROUP BY \(profile[.id]) ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC, + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, IFNULL(\(profile[.nickname]), \(profile[.name])) ASC """) @@ -140,7 +133,7 @@ public extension MentionInfo { \(targetWhere) GROUP BY \(profile[.id]) ORDER BY - \(SQL("\(profile[.id]) IN \(currentUserIds)")) DESC, + \(SQL("\(profile[.id]) IN \(currentUserSessionIds)")) DESC, \(interaction[.timestampMs].desc) LIMIT 20 """) diff --git a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift index c18d9a44c4..233760ac58 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel+DeletionActions.swift @@ -161,7 +161,8 @@ public extension MessageViewModel.DeletionBehaviours { db, publicKey: threadData.currentUserSessionId, for: roomToken, - on: server + on: server, + currentUserSessionIds: (threadData.currentUserSessionIds ?? []) ) } }() @@ -364,7 +365,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.contact, _): /// Only include messages sent by the current user (can't delete incoming messages in contact conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { $0.authorId == threadData.currentUserSessionId } + .filter { threadData.currentUserSessionId.contains($0.authorId) } let serverHashes: Set = try Interaction.serverHashesForDeletion( db, interactionIds: targetViewModels.map { $0.id }.asSet() @@ -439,7 +440,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.legacyGroup, _): /// Only try to delete messages send by other users if the current user is an admin let targetViewModels: [MessageViewModel] = cellViewModels - .filter { isAdmin || $0.authorId == threadData.currentUserSessionId } + .filter { isAdmin || (threadData.currentUserSessionIds ?? []).contains($0.authorId) } let unsendRequests: [Network.PreparedRequest] = try targetViewModels.map { model in try MessageSender.preparedSend( db, @@ -496,7 +497,7 @@ public extension MessageViewModel.DeletionBehaviours { case (.group, false): /// Only include messages sent by the current user (non-admins can't delete incoming messages in group conversations) let targetViewModels: [MessageViewModel] = cellViewModels - .filter { $0.authorId == threadData.currentUserSessionId } + .filter { (threadData.currentUserSessionIds ?? []).contains($0.authorId) } let serverHashes: Set = try Interaction.serverHashesForDeletion( db, interactionIds: targetViewModels.map { $0.id }.asSet() diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index c28f7cb59b..1522f5e1b7 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -14,6 +14,7 @@ fileprivate typealias ReactionInfo = MessageViewModel.ReactionInfo fileprivate typealias TypingIndicatorInfo = MessageViewModel.TypingIndicatorInfo fileprivate typealias QuotedInfo = MessageViewModel.QuotedInfo +// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -72,8 +73,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, case isOnlyMessageInCluster case isLast case isLastOutgoing - case currentUserBlinded15SessionId - case currentUserBlinded25SessionId + case currentUserSessionIds case optimisticMessageId } @@ -196,11 +196,8 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let isLastOutgoing: Bool - /// This is the users blinded15 sessionId hex string (will only be set for messages within open groups) - public let currentUserBlinded15SessionId: String? - - /// This is the users blinded25 sessionId hex string (will only be set for messages within open groups) - public let currentUserBlinded25SessionId: String? + /// This contains all sessionId values for the current user (standard and any blinded variants) + public let currentUserSessionIds: Set? /// This is a temporary id used before an outgoing message is persisted into the database public let optimisticMessageId: UUID? @@ -263,8 +260,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, + currentUserSessionIds: self.currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -325,8 +321,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: self.isOnlyMessageInCluster, isLast: self.isLast, isLastOutgoing: self.isLastOutgoing, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, + currentUserSessionIds: self.currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -336,8 +331,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, nextModel: MessageViewModel?, isLast: Bool, isLastOutgoing: Bool, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, using dependencies: Dependencies ) -> MessageViewModel { let cellType: CellType = { @@ -545,8 +539,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, isOnlyMessageInCluster: isOnlyMessageInCluster, isLast: isLast, isLastOutgoing: isLastOutgoing, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, optimisticMessageId: self.optimisticMessageId ) } @@ -766,8 +759,7 @@ public extension MessageViewModel { self.isOnlyMessageInCluster = true self.isLast = isLast self.isLastOutgoing = isLastOutgoing - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [currentUserSessionId] self.optimisticMessageId = nil } @@ -851,8 +843,7 @@ public extension MessageViewModel { self.isOnlyMessageInCluster = true self.isLast = false self.isLastOutgoing = false - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [currentUserProfile.id] self.optimisticMessageId = optimisticMessageId } } @@ -920,8 +911,7 @@ public extension MessageViewModel { static func baseQuery( userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId?, + currentUserSessionIds: Set, orderSQL: SQL, groupSQL: SQL? ) -> (([Int64]) -> AdaptedFetchRequest>) { @@ -1022,10 +1012,7 @@ public extension MessageViewModel { -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR - \(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''") - ) + \(quote[.authorId]) IN \(currentUserSessionIds) ) ) ) @@ -1299,8 +1286,7 @@ public extension MessageViewModel.TypingIndicatorInfo { public extension MessageViewModel.QuotedInfo { static func baseQuery( userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? + currentUserSessionIds: Set ) -> ((SQL?) -> AdaptedFetchRequest>) { return { additionalFilters -> AdaptedFetchRequest> in let quote: TypedTableAlias = TypedTableAlias() @@ -1335,10 +1321,7 @@ public extension MessageViewModel.QuotedInfo { -- A users outgoing message is stored in some cases using their standard id -- but the quote will use their blinded id so handle that case \(quoteInteraction[.authorId]) = \(userSessionId.hexString) AND - ( - \(quote[.authorId]) = \(blinded15SessionId?.hexString ?? "''") OR - \(quote[.authorId]) = \(blinded25SessionId?.hexString ?? "''") - ) + \(quote[.authorId]) IN \(currentUserSessionIds) ) ) ) @@ -1368,11 +1351,7 @@ public extension MessageViewModel.QuotedInfo { } } - static func joinToViewModelQuerySQL( - userSessionId: SessionId, - blinded15SessionId: SessionId?, - blinded25SessionId: SessionId? - ) -> SQL { + static func joinToViewModelQuerySQL() -> SQL { let quote: TypedTableAlias = TypedTableAlias() let interaction: TypedTableAlias = TypedTableAlias() diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 8779e0af38..fbdd059f58 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -16,6 +16,7 @@ fileprivate typealias ViewModel = SessionThreadViewModel /// /// **Note:** When updating the UI make sure to check the actual queries being run as some fields will have incorrect default values /// in order to optimise their queries to only include the required data +// TODO: [Database Relocation] Refactor this to split database data from no-database data (to avoid unneeded nullables) public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equatable, Hashable, Identifiable, Differentiable, ColumnExpressible, ThreadSafeType { public typealias Columns = CodingKeys public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable { @@ -85,8 +86,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat case threadContactNameInternal case authorNameInternal case currentUserSessionId - case currentUserBlinded15SessionId - case currentUserBlinded25SessionId + case currentUserSessionIds case recentReactionEmoji case wasKickedFromGroup case groupIsDestroyed @@ -188,8 +188,7 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat private let threadContactNameInternal: String? private let authorNameInternal: String? public let currentUserSessionId: String - public let currentUserBlinded15SessionId: String? - public let currentUserBlinded25SessionId: String? + public let currentUserSessionIds: Set? public let recentReactionEmoji: [String]? public let wasKickedFromGroup: Bool? public let groupIsDestroyed: Bool? @@ -426,17 +425,12 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat guard wasKickedFromGroup != true else { return false } guard threadIsMessageRequest == false else { return true } - // Double check LibSession directly just in case we the view model hasn't been - // updated since they were changed + /// Double check `libSession` directly just in case we the view model hasn't been updated since they were changed guard - !LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) && - !LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession, { cache in + !cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadId)) && + !cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadId)) + }) else { return false } return interactionVariant?.isGroupLeavingStatus != true @@ -539,8 +533,7 @@ public extension SessionThreadViewModel { self.threadContactNameInternal = nil self.authorNameInternal = nil self.currentUserSessionId = dependencies[cache: .general].sessionId.hexString - self.currentUserBlinded15SessionId = nil - self.currentUserBlinded25SessionId = nil + self.currentUserSessionIds = [dependencies[cache: .general].sessionId.hexString] self.recentReactionEmoji = nil self.wasKickedFromGroup = false self.groupIsDestroyed = false @@ -610,8 +603,7 @@ public extension SessionThreadViewModel { threadContactNameInternal: self.threadContactNameInternal, authorNameInternal: self.authorNameInternal, currentUserSessionId: self.currentUserSessionId, - currentUserBlinded15SessionId: self.currentUserBlinded15SessionId, - currentUserBlinded25SessionId: self.currentUserBlinded25SessionId, + currentUserSessionIds: self.currentUserSessionIds, recentReactionEmoji: (recentReactionEmoji ?? self.recentReactionEmoji), wasKickedFromGroup: self.wasKickedFromGroup, groupIsDestroyed: self.groupIsDestroyed @@ -619,13 +611,10 @@ public extension SessionThreadViewModel { } func populatingPostQueryData( - _ db: Database? = nil, - currentUserBlinded15SessionIdForThisThread: String?, - currentUserBlinded25SessionIdForThisThread: String?, + currentUserSessionIds: Set, wasKickedFromGroup: Bool, groupIsDestroyed: Bool, - threadCanWrite: Bool, - using dependencies: Dependencies + threadCanWrite: Bool ) -> SessionThreadViewModel { return SessionThreadViewModel( rowId: self.rowId, @@ -684,26 +673,7 @@ public extension SessionThreadViewModel { threadContactNameInternal: self.threadContactNameInternal, authorNameInternal: self.authorNameInternal, currentUserSessionId: self.currentUserSessionId, - currentUserBlinded15SessionId: ( - currentUserBlinded15SessionIdForThisThread ?? - SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: self.threadId, - threadVariant: self.threadVariant, - blindingPrefix: .blinded15, - using: dependencies - )?.hexString - ), - currentUserBlinded25SessionId: ( - currentUserBlinded25SessionIdForThisThread ?? - SessionThread.getCurrentUserBlindedSessionId( - db, - threadId: self.threadId, - threadVariant: self.threadVariant, - blindingPrefix: .blinded25, - using: dependencies - )?.hexString - ), + currentUserSessionIds: currentUserSessionIds, recentReactionEmoji: self.recentReactionEmoji, wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed diff --git a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift index 4483d49e32..4275cc9d48 100644 --- a/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift +++ b/SessionMessagingKit/Utilities/Authentication+SessionMessagingKit.swift @@ -11,20 +11,22 @@ public extension Authentication { /// Used for when interacting as the current user struct standard: AuthenticationMethod { public let sessionId: SessionId - public let ed25519KeyPair: KeyPair + public let ed25519PublicKey: [UInt8] + public let ed25519SecretKey: [UInt8] - public var info: Info { .standard(sessionId: sessionId, ed25519KeyPair: ed25519KeyPair) } + public var info: Info { .standard(sessionId: sessionId, ed25519PublicKey: ed25519PublicKey) } - public init(sessionId: SessionId, ed25519KeyPair: KeyPair) { + public init(sessionId: SessionId, ed25519PublicKey: [UInt8], ed25519SecretKey: [UInt8]) { self.sessionId = sessionId - self.ed25519KeyPair = ed25519KeyPair + self.ed25519PublicKey = ed25519PublicKey + self.ed25519SecretKey = ed25519SecretKey } // MARK: - SignatureGenerator public func generateSignature(with verificationBytes: [UInt8], using dependencies: Dependencies) throws -> Authentication.Signature { return try dependencies[singleton: .crypto].tryGenerate( - .signature(message: verificationBytes, ed25519SecretKey: ed25519KeyPair.secretKey) + .signature(message: verificationBytes, ed25519SecretKey: ed25519SecretKey) ) } } @@ -93,11 +95,17 @@ public extension Authentication { ) throws -> AuthenticationMethod { switch try? SessionId(from: swarmPublicKey) { case .some(let sessionId) where sessionId.prefix == .standard: - guard let keyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) else { - throw SnodeAPIError.noKeyPair - } + guard + let userEdKeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: dependencies[cache: .general].ed25519Seed) + ) + else { throw SnodeAPIError.noKeyPair } - return Authentication.standard(sessionId: sessionId, ed25519KeyPair: keyPair) + return Authentication.standard( + sessionId: sessionId, + ed25519PublicKey: userEdKeyPair.publicKey, + ed25519SecretKey: userEdKeyPair.secretKey + ) case .some(let sessionId) where sessionId.prefix == .group: let authData: GroupAuthData? = try? ClosedGroup diff --git a/SessionMessagingKit/Utilities/DisplayPictureManager.swift b/SessionMessagingKit/Utilities/DisplayPictureManager.swift index 9d37846203..6a23097027 100644 --- a/SessionMessagingKit/Utilities/DisplayPictureManager.swift +++ b/SessionMessagingKit/Utilities/DisplayPictureManager.swift @@ -326,7 +326,7 @@ public class DisplayPictureManager { // Encrypt the avatar for upload guard let encryptedData: Data = dependencies[singleton: .crypto].generate( - .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey, using: dependencies) + .encryptedDataDisplayPicture(data: finalImageData, key: newEncryptionKey) ) else { Log.error(.displayPictureManager, "Updating service with profile failed.") diff --git a/SessionMessagingKit/Utilities/ExtensionHelper.swift b/SessionMessagingKit/Utilities/ExtensionHelper.swift new file mode 100644 index 0000000000..5af03d8fd6 --- /dev/null +++ b/SessionMessagingKit/Utilities/ExtensionHelper.swift @@ -0,0 +1,215 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionSnodeKit +import SessionUtilitiesKit + +// MARK: - Singleton + +public extension Singleton { + static let extensionHelper: SingletonConfig = Dependencies.create( + identifier: "extensionHelper", + createInstance: { dependencies in ExtensionHelper(using: dependencies) } + ) +} + +// MARK: - KeychainStorage + +// stringlint:ignore_contents +public extension KeychainStorage.DataKey { + static let extensionEncryptionKey: Self = "ExtensionEncryptionKeyKey" +} + +// MARK: - Log.Category + +private extension Log.Category { + static let cat: Log.Category = .create("ExtensionHelper", defaultLevel: .info) +} + +// MARK: - ExtensionHelper + +public class ExtensionHelper: ExtensionHelperType { + private lazy var cacheDirectoryPath: String = "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/extensionCache" + private lazy var dedupePath: String = "\(cacheDirectoryPath)/dedupe" + private func dumpFilePath(_ hash: [UInt8]) -> String { + return "\(cacheDirectoryPath)/\(hash.toHexString())" + } + private let encryptionKeyLength: Int = 32 + + private let dependencies: Dependencies + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - File Management + + private func write(data: Data, to path: String) throws { + /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends + guard + var encKey: [UInt8] = (try? dependencies[singleton: .keychain] + .getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) + else { throw ExtensionHelperError.noEncryptionKey } + defer { encKey.resetBytes(in: 0.. String? { + guard + let threadIdData: Data = "ConvoIdSalt-\(threadId)".data(using: .utf8), + let threadIdHash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array(threadIdData)) + ) + else { return nil } + + return URL(fileURLWithPath: dedupePath) + .appendingPathComponent(threadIdHash.toHexString()) + .path + } + + // stringlint:ignore_contents + private func dedupeRecordPath(_ threadId: String, _ uniqueIdentifier: String) -> String? { + guard + let threadDedupePath: String = threadDedupeRecordPath(threadId), + let uniqueIdData: Data = "UniqueIdSalt-\(uniqueIdentifier)".data(using: .utf8), + let uniqueIdHash: [UInt8] = dependencies[singleton: .crypto].generate( + .hash(message: Array(uniqueIdData)) + ) + else { return nil } + + return URL(fileURLWithPath: threadDedupePath) + .appendingPathComponent(uniqueIdHash.toHexString()) + .path + } + + public func hasAtLeastOneDedupeRecord(threadId: String) -> Bool { + guard let path: String = threadDedupeRecordPath(threadId) else { return false } + + return !dependencies[singleton: .fileManager].isDirectoryEmpty(atPath: path) + } + + public func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { return false } + + return dependencies[singleton: .fileManager].fileExists(atPath: path) + } + + public func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { + throw ExtensionHelperError.failedToStoreDedupeRecord + } + + try write(data: Data(), to: path) + } + + public func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + guard let path: String = dedupeRecordPath(threadId, uniqueIdentifier) else { + throw ExtensionHelperError.failedToRemoveDedupeRecord + } + + try dependencies[singleton: .fileManager].removeItem(atPath: path) + + /// Also remove the directory if it's empty + let parentDirectory: String = URL(fileURLWithPath: path) + .deletingLastPathComponent() + .path + + if dependencies[singleton: .fileManager].isDirectoryEmpty(atPath: parentDirectory) { + try? dependencies[singleton: .fileManager].removeItem(atPath: parentDirectory) + } + } + + public func deleteAllDedupeRecords() { + try? dependencies[singleton: .fileManager].removeItem(atPath: dedupePath) + } + + // MARK: - Config Dumps + + private func hash(for sessionId: SessionId, variant: ConfigDump.Variant) -> [UInt8]? { + return "\(sessionId.hexString)-\(variant)".data(using: .utf8).map { dataToHash in + dependencies[singleton: .crypto].generate( + .hash(message: Array(dataToHash)) + ) + } + } + + public func lastUpdatedTimestamp( + for sessionId: SessionId, + variant: ConfigDump.Variant + ) -> TimeInterval { + guard let hash: [UInt8] = hash(for: sessionId, variant: variant) else { return 0 } + + return ((try? dependencies[singleton: .fileManager] + .attributesOfItem(atPath: "\(dumpFilePath(hash))") + .getting(.modificationDate) as? Date)? + .timeIntervalSince1970) + .defaulting(to: 0) + } +} + +// MARK: - ExtensionHelperError + +public enum ExtensionHelperError: Error, CustomStringConvertible { + case noEncryptionKey + case failedToWriteToFile + case failedToStoreDedupeRecord + case failedToRemoveDedupeRecord + + // stringlint:ignore_contents + public var description: String { + switch self { + case .noEncryptionKey: return "No encryption key available." + case .failedToWriteToFile: return "Failed to write to file." + case .failedToStoreDedupeRecord: return "Failed to store a record for message deduplication." + case .failedToRemoveDedupeRecord: return "Failed to remove a record for message deduplication." + } + } +} + +// MARK: - ExtensionHelperType + +public protocol ExtensionHelperType { + // MARK: - Deduping + + func hasAtLeastOneDedupeRecord(threadId: String) -> Bool + func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool + func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws + func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws + func deleteAllDedupeRecords() + + // MARK: - Config Dumps + + func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval +} diff --git a/SessionMessagingKit/Utilities/Preferences+NotificationMode.swift b/SessionMessagingKit/Utilities/Preferences+NotificationMode.swift new file mode 100644 index 0000000000..5608817878 --- /dev/null +++ b/SessionMessagingKit/Utilities/Preferences+NotificationMode.swift @@ -0,0 +1,22 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit +import SessionUtilitiesKit + +public extension Preferences { + enum NotificationMode: Int, CaseIterable, EnumIntSetting, Differentiable { + /// Notifications should be shown for all messages + case all + + /// Notifications should be shown only for messages which mention the current user + case mentionsOnly + + /// Notifications should not be shown for any messages + case none + + public static func defaultMode(for threadVariant: SessionThread.Variant) -> NotificationMode { + return .all + } + } +} diff --git a/SessionMessagingKit/Utilities/Preferences+Sound.swift b/SessionMessagingKit/Utilities/Preferences+Sound.swift index 8812f2fa1a..d08b9d1a2e 100644 --- a/SessionMessagingKit/Utilities/Preferences+Sound.swift +++ b/SessionMessagingKit/Utilities/Preferences+Sound.swift @@ -159,7 +159,8 @@ public extension Preferences { ) } - public func notificationSound(isQuiet: Bool) -> UNNotificationSound { + public func notificationSound(isQuiet: Bool) -> UNNotificationSound? { + guard self != .none else { return nil } guard let filename: String = filename(quiet: isQuiet) else { Log.warn(.cat, "Filename was unexpectedly nil") return UNNotificationSound.default diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 99b55ed520..b329835fcf 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -114,6 +114,22 @@ public extension Setting.IntKey { } public enum Preferences { + public struct NotificationSettings { + public static func defaultFor(_ threadVariant: SessionThread.Variant) -> NotificationSettings { + return NotificationSettings( + mode: .defaultMode(for: threadVariant), + previewType: .defaultPreviewType, + sound: .defaultNotificationSound, + mutedUntil: nil + ) + } + + public let mode: NotificationMode + public let previewType: Preferences.NotificationPreviewType + public let sound: Preferences.Sound + public let mutedUntil: TimeInterval? + } + // stringlint:ignore_contents public static var isCallKitSupported: Bool { #if targetEnvironment(simulator) diff --git a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift index 27bec19d55..7de5f146de 100644 --- a/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift +++ b/SessionMessagingKitTests/Crypto/CryptoSMKSpec.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit import Quick @@ -14,20 +13,13 @@ class CryptoSMKSpec: QuickSpec { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - using: dependencies, - initialData: { db in - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: dependencies) + @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( + initialSetup: { cache in + cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) } ) - @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: .any) - @TestState var mockCrypto: MockCrypto! = MockCrypto() // MARK: - Crypto for SessionMessagingKit describe("Crypto for SessionMessagingKit") { @@ -78,16 +70,12 @@ class CryptoSMKSpec: QuickSpec { context("when encrypting with the session protocol") { // MARK: ---- can encrypt correctly it("can encrypt correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)"), - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + destination: .contact(publicKey: "05\(TestConstants.publicKey)") ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -96,24 +84,17 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - destination: .contact(publicKey: "05\(TestConstants.publicKey)"), - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + destination: .contact(publicKey: "05\(TestConstants.publicKey)") ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } } @@ -121,19 +102,15 @@ class CryptoSMKSpec: QuickSpec { context("when decrypting with the session protocol") { // MARK: ---- successfully decrypts a message it("successfully decrypts a message") { - let result = mockStorage.read { db in - crypto.generate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - using: dependencies - ) + let result = crypto.generate( + .plaintextWithSessionProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )! ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex) @@ -142,43 +119,32 @@ class CryptoSMKSpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )! ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } // MARK: ---- throws an error if the ciphertext is too short it("throws an error if the ciphertext is too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionProtocol( - db, - ciphertext: Data([1, 2, 3]), - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionProtocol( + ciphertext: Data([1, 2, 3]) ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } } } diff --git a/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift new file mode 100644 index 0000000000..de91337308 --- /dev/null +++ b/SessionMessagingKitTests/Database/Models/MessageDeduplicationSpec.swift @@ -0,0 +1,883 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import SessionSnodeKit +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class MessageDeduplicationSpec: QuickSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies() + + @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( + customWriter: try! DatabaseQueue(), + migrationTargets: [ + SNMessagingKit.self + ], + using: dependencies + ) + @TestState(singleton: .extensionHelper, in: dependencies) var mockExtensionHelper: MockExtensionHelper! = MockExtensionHelper( + initialSetup: { helper in + helper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(false) + helper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(()) + helper + .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(()) + helper.when { $0.deleteAllDedupeRecords() }.thenReturn(()) + } + ) + @TestState var mockMessage: Message! = { + let result: ReadReceipt = ReadReceipt(timestamps: [1]) + result.sentTimestampMs = 12345678901234 + + return result + }() + + // MARK: - a MessageDeduplication + describe("a MessageDeduplication") { + // MARK: -- when inserting + context("when inserting") { + // MARK: ---- inserts a record correctly + it("inserts a record correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + message: nil, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.count).to(equal(1)) + expect(records?.first?.threadId).to(equal("testThreadId")) + expect(records?.first?.uniqueIdentifier).to(equal("testId")) + expect(records?.first?.expirationTimestampSeconds) + .to(equal(1234567890 + (SnodeReceivedMessage.serverClockToleranceMs * 2))) + expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- checks that it is not a duplicate record + it("checks that it is not a duplicate record") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + message: nil, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- creates a legacy record if needed + it("creates a legacy record if needed") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.toNot(throwError()) + } + + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") + }) + } + + // MARK: ---- sets the shouldDeleteWhenDeletingThread flag correctly + it("sets the shouldDeleteWhenDeletingThread flag correctly") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId1", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .community, + uniqueIdentifier: "testId2", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .legacyGroup, + uniqueIdentifier: "testId3", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId4", + message: GroupUpdateInviteMessage( + inviteeSessionIdHexString: "TestId", + groupSessionId: SessionId(.group, hex: TestConstants.publicKey), + groupName: "TestGroup", + memberAuthData: Data([1, 2, 3]), + profile: nil, + adminSignature: .standard(signature: "TestSignature".bytes) + ), + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId5", + message: GroupUpdatePromoteMessage( + groupIdentitySeed: Data([1, 2, 3]), + groupName: "TestGroup", + sentTimestampMs: 1234567890000 + ), + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId6", + message: GroupUpdateMemberLeftMessage(), + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId7", + message: GroupUpdateInviteResponseMessage( + isApproved: true, + profile: nil, + sentTimestampMs: 1234567800000 + ), + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .group, + uniqueIdentifier: "testId8", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: nil, + uniqueIdentifier: "testId9", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: nil, + uniqueIdentifier: "testId10", + message: nil, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [String: MessageDeduplication] = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + .defaulting(to: []) + .reduce(into: [:]) { result, next in result[next.uniqueIdentifier] = next } + expect(records["testId1"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId2"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId3"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId4"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId5"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId6"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId7"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId8"]?.shouldDeleteWhenDeletingThread).to(beTrue()) + expect(records["testId9"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(records["testId10"]?.shouldDeleteWhenDeletingThread).to(beFalse()) + } + + // MARK: ---- does nothing if no uniqueIdentifier is provided + it("does nothing if no uniqueIdentifier is provided") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: nil, + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(beEmpty()) + } + + // MARK: ---- creates a record from a ProcessedMessage + it("creates a record from a ProcessedMessage") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + processedMessage: .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: mockMessage, + variant: .readReceipt, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + } + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "LegacyRecord-1-12345678901234" + ) + }) + } + + // MARK: ---- creates a record with no expiration for a config ProcessedMessage + it("creates a record with no expiration for a config ProcessedMessage") { + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + processedMessage: .config( + publicKey: "testThreadId", + namespace: .configContacts, + serverHash: "1234", + serverTimestampMs: 1234567890, + data: Data([1, 2, 3]), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records?.first?.threadId).to(equal("testThreadId")) + expect(records?.first?.uniqueIdentifier).to(equal("testId")) + expect(records?.first?.expirationTimestampSeconds).to(beNil()) + expect(records?.first?.shouldDeleteWhenDeletingThread).to(beFalse()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- throws if the message is a duplicate + it("throws if the message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + } + + // MARK: ---- throws if the message is a legacy duplicate + it("throws if the message is a legacy duplicate") { + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(false) + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenReturn(true) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + } + + // MARK: ---- throws if it fails to create the dedupe file + it("throws if it fails to create the dedupe file") { + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: ---- throws if it fails to create the legacy dedupe file + it("throws if it fails to create the legacy dedupe file") { + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.insert( + db, + threadId: "testThreadId", + threadVariant: .contact, + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + message: mockMessage, + serverExpirationTimestamp: 1234567890, + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + } + } + + // MARK: -- when deleting a dedupe record + context("when deleting a dedupe record") { + // MARK: ---- deletes the record successfully + it("deletes the record successfully") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(beEmpty()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- deletes multiple records + it("deletes multiple records") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId2", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect(records).to(beEmpty()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId2") + }) + } + + // MARK: ---- leaves unrelated records + it("leaves unrelated records") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + try MessageDeduplication( + threadId: "testThreadId2", + uniqueIdentifier: "testId2", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId2"])) + expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) + .to(equal(["testId2"])) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- leaves records which should not be deleted alongside the thread + it("leaves records which should not be deleted alongside the thread") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: nil, + shouldDeleteWhenDeletingThread: false + ).insert(db) + } + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"])) + expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) + .to(equal(["testId"])) + expect(mockExtensionHelper).toNot(call { + try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) + }) + } + + // MARK: ---- resets the expiration timestamp when failing to delete the file + it("resets the expiration timestamp when failing to delete the file") { + mockStorage.write { db in + try MessageDeduplication( + threadId: "testThreadId", + uniqueIdentifier: "testId", + expirationTimestampSeconds: 1234567890, + shouldDeleteWhenDeletingThread: true + ).insert(db) + } + mockExtensionHelper + .when { try $0.removeDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + mockStorage.write { db in + expect { + try MessageDeduplication.deleteIfNeeded( + db, + threadIds: ["testThreadId"], + using: dependencies + ) + }.toNot(throwError()) + } + + let records: [MessageDeduplication]? = mockStorage + .read { db in try MessageDeduplication.fetchAll(db) } + expect((records?.map { $0.threadId }).map { Set($0) }).to(equal(["testThreadId"])) + expect((records?.map { $0.uniqueIdentifier }).map { Set($0) }) + .to(equal(["testId"])) + expect((records?.map { $0.expirationTimestampSeconds }).map { Set($0) }) + .to(equal([0])) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + } + + // MARK: -- when creating a dedupe file + context("when creating a dedupe file") { + // MARK: ---- creates the file successfully + it("creates the file successfully") { + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.toNot(throwError()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- creates both the main file and a legacy file when needed + it("creates both the main file and a legacy file when needed") { + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + using: dependencies + ) + }.toNot(throwError()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + }) + } + + // MARK: ---- creates a file from a ProcessedMessage + it("creates a file from a ProcessedMessage") { + expect { + try MessageDeduplication.createDedupeFile( + .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: Message(), + variant: .visibleMessage, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + } + + // MARK: ---- throws when it fails to create the file + it("throws when it fails to create the file") { + mockExtensionHelper + .when { try $0.createDedupeRecord(threadId: .any, uniqueIdentifier: .any) } + .thenThrow(TestError.mock) + + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.to(throwError(TestError.mock)) + } + + // MARK: ---- throws when it fails to create the legacy file + it("throws when it fails to create the legacy file") { + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(()) + mockExtensionHelper + .when { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenThrow(TestError.mock) + + expect { + try MessageDeduplication.createDedupeFile( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + using: dependencies + ) + }.to(throwError(TestError.mock)) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.createDedupeRecord( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + }) + } + } + + // MARK: -- when ensuring a message is not a duplicate + context("when ensuring a message is not a duplicate") { + // MARK: ---- does not throw when not a duplicate + it("does not throw when not a duplicate") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- when ensuring a message is not a legacy duplicate + it("does not throw when not a legacy duplicate") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- does not throw when given a non duplicate ProcessedMessage + it("does not throw when given a non duplicate ProcessedMessage") { + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + .standard( + threadId: "testThreadId", + threadVariant: .contact, + proto: try! SNProtoContent.builder().build(), + messageInfo: MessageReceiveJob.Details.MessageInfo( + message: Message(), + variant: .visibleMessage, + threadVariant: .contact, + serverExpirationTimestamp: nil, + proto: try! SNProtoContent.builder().build() + ), + uniqueIdentifier: "testId" + ), + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- throws when the message is a duplicate + it("throws when the message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + } + + // MARK: ---- throws when the message is a legacy duplicate + it("throws when the message is a legacy duplicate") { + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testId" + ) + } + .thenReturn(false) + mockExtensionHelper + .when { + $0.dedupeRecordExists( + threadId: "testThreadId", + uniqueIdentifier: "testLegacyId" + ) + } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureMessageIsNotADuplicate( + threadId: "testThreadId", + uniqueIdentifier: "testId", + legacyIdentifier: "testLegacyId", + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicateMessage)) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testId") + }) + expect(mockExtensionHelper).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.dedupeRecordExists(threadId: "testThreadId", uniqueIdentifier: "testLegacyId") + }) + } + } + + // MARK: -- when ensuring a call message is not a duplicate + context("when ensuring a call message is not a duplicate") { + // MARK: ---- does not throw when not a duplicate + it("does not throw when not a duplicate") { + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: nil + ), + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- does nothing if no call message is provided + it("does nothing if no call message is provided") { + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: nil, + using: dependencies + ) + }.toNot(throwError()) + } + + // MARK: ---- throws when the call message is a duplicate + it("throws when the call message is a duplicate") { + mockExtensionHelper + .when { $0.dedupeRecordExists(threadId: .any, uniqueIdentifier: .any) } + .thenReturn(true) + + expect { + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: "testThreadId", + callMessage: CallMessage( + uuid: "12345", + kind: .preOffer, + sdps: [], + sentTimestampMs: nil + ), + using: dependencies + ) + }.to(throwError(MessageReceiverError.duplicatedCall)) + } + } + } + } +} diff --git a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift index 5cd815814d..71fc70d986 100644 --- a/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/DisplayPictureDownloadJobSpec.swift @@ -23,18 +23,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache.when { $0.config(for: .any, sessionId: .any) }.thenReturn(nil) - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(false) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), @@ -93,7 +82,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { initialSetup: { crypto in crypto.when { $0.generate(.uuid()) }.thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000001234")) crypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(imageData) crypto.when { $0.generate(.hash(message: .any, length: .any)) }.thenReturn("TestHash".bytes) crypto @@ -118,6 +107,15 @@ class DisplayPictureDownloadJobSpec: QuickSpec { displayPictureCache.when { $0.imageData = .any }.thenReturn(()) } ) + @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( + initialSetup: { cache in + cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) + } + ) // MARK: - a DisplayPictureDownloadJob describe("a DisplayPictureDownloadJob") { @@ -633,7 +631,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it fails to decrypt the data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(nil) } @@ -650,7 +648,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { context("when it decrypts invalid image data") { beforeEach { mockCrypto - .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(Data([1, 2, 3])) } @@ -742,7 +740,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -768,7 +766,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -804,7 +802,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -839,7 +837,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockCrypto) .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( @@ -936,7 +934,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -962,7 +960,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -1004,7 +1002,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("does not save the picture") { expect(mockCrypto) .toNot(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager) .toNot(call { $0.createFile(atPath: .any, contents: .any, attributes: .any) }) @@ -1045,7 +1043,7 @@ class DisplayPictureDownloadJobSpec: QuickSpec { it("saves the picture") { expect(mockCrypto) .to(call { - $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any, using: .any)) + $0.generate(.decryptedDataDisplayPicture(data: .any, key: .any)) }) expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { $0.createFile( diff --git a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift index 9607f39ee8..24bda0cfc2 100644 --- a/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/MessageSendJobSpec.swift @@ -31,14 +31,7 @@ class MessageSendJobSpec: QuickSpec { dependencies.dateNow = Date(timeIntervalSince1970: 1234567890) } @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache.when { $0.config(for: .any, sessionId: .any) }.thenReturn(nil) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( customWriter: try! DatabaseQueue(), diff --git a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift index 121819e34c..4d128d6c0f 100644 --- a/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift +++ b/SessionMessagingKitTests/Jobs/RetrieveDefaultOpenGroupRoomsJobSpec.swift @@ -86,6 +86,15 @@ class RetrieveDefaultOpenGroupRoomsJobSpec: QuickSpec { cache.when { $0.setDefaultRoomInfo(.any) }.thenReturn(()) } ) + @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( + initialSetup: { cache in + cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) + } + ) @TestState var job: Job! = Job(variant: .retrieveDefaultOpenGroupRooms) @TestState var error: Error? = nil @TestState var permanentFailure: Bool! = false diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift index 1fca27cccc..bc8674366c 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupInfoSpec.swift @@ -23,6 +23,7 @@ class LibSessionGroupInfoSpec: QuickSpec { @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( @@ -80,25 +81,15 @@ class LibSessionGroupInfoSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.removeConfigs(for: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) + cache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) cache.when { $0.configNeedsDump(.any) }.thenReturn(true) - cache.when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) }.thenReturn(nil) - cache.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }.thenReturn(nil) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) } ) diff --git a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift index 6b87e202a8..87e46e2be6 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionGroupMembersSpec.swift @@ -22,6 +22,7 @@ class LibSessionGroupMembersSpec: QuickSpec { @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( @@ -78,21 +79,14 @@ class LibSessionGroupMembersSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) - cache.when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) }.thenReturn(nil) - cache.when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + cache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) } ) diff --git a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift index 8f583852f1..ea02102135 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionSpec.swift @@ -22,6 +22,7 @@ class LibSessionSpec: QuickSpec { @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) } ) @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( @@ -89,33 +90,14 @@ class LibSessionSpec: QuickSpec { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) _ = user_groups_init(&conf, &secretKey, nil, 0, nil) - cache.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) - cache.when { $0.config(for: .userGroups, sessionId: .any) } - .thenReturn(.userGroups(conf)) - cache.when { $0.config(for: .groupInfo, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupInfo]) - cache.when { $0.config(for: .groupMembers, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupMembers]) - cache.when { $0.config(for: .groupKeys, sessionId: .any) } - .thenReturn(createGroupOutput.groupState[.groupKeys]) - cache.when { $0.configNeedsDump(.any) }.thenReturn(false) - cache - .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } - .thenReturn(nil) - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .then { args, untrackedArgs in - let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(.userGroups(conf)) - case .groupInfo: try? callback?(createGroupOutput.groupState[.groupInfo]) - case .groupMembers: try? callback?(createGroupOutput.groupState[.groupMembers]) - case .groupKeys: try? callback?(createGroupOutput.groupState[.groupKeys]) - default: break - } - } - .thenReturn(()) + cache.defaultInitialSetup( + configs: [ + .userGroups: .userGroups(conf), + .groupInfo: createGroupOutput.groupState[.groupInfo], + .groupMembers: createGroupOutput.groupState[.groupMembers], + .groupKeys: createGroupOutput.groupState[.groupKeys] + ] + ) } ) @TestState var userGroupsConfig: LibSession.Config! @@ -345,11 +327,8 @@ class LibSessionSpec: QuickSpec { // MARK: ---- throws when there is no user ed25519 keyPair it("throws when there is no user ed25519 keyPair") { var resultError: Error? = nil - + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - do { _ = try LibSession.createGroup( db, diff --git a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift index 032407d486..ac6961435c 100644 --- a/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift +++ b/SessionMessagingKitTests/LibSession/LibSessionUtilSpec.swift @@ -2864,62 +2864,3 @@ fileprivate extension LibSessionUtilSpec { } } } - -// MARK: - Convenience - -private extension LibSessionUtilSpec { - static func has(_ conf: UnsafeMutablePointer?, with numRecords: inout Int, hitLimit expectedLimit: Int) -> Bool { - // Have a hard limit (ie. don't want to loop over this limit as it likely means something is busted elsewhere - // and we are in an infinite loop) - guard numRecords < 2500 else { return true } - - // When generating push data the actual data generated is based on a diff from the current state to the - // next state - this means that adding 100 records at once is a different size from adding 1 at a time, - // but since adding them 1 at a time is really inefficient we want to try to be smart about calling - // `config_push` when we are far away from the limit, but do so in such a way that we still get accurate - // sizes as we approach the limit (this includes the "diff" values which include the last 5 changes) - // - // **Note:** `config_push` returns null when it hits the config limit - let distanceToLimit: Int = (expectedLimit - numRecords) - - switch distanceToLimit { - case Int.min...50: - // Within 50 records of the expected limit we want to check every record - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - - case 50...100: - // Between 50 and 100 records of the expected limit only check every `10` records - if numRecords.isMultiple(of: 10) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - - case 100...200: - // Between 100 and 200 records of the expected limit only check every `25` records - if numRecords.isMultiple(of: 25) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - - default: - // Otherwise check every `50` records - if numRecords.isMultiple(of: 50) { - guard let result: UnsafeMutablePointer = config_push(conf) else { return true } - - // We successfully generated the config push and didn't hit the limit - free(UnsafeMutableRawPointer(mutating: result)) - } - } - - // Increment the number of records - numRecords += 1 - return false - } -} diff --git a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift index 4771cf2052..35b3ba1679 100644 --- a/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/Crypto/CryptoOpenGroupAPISpec.swift @@ -1,7 +1,6 @@ // Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB import SessionUtilitiesKit import Quick @@ -14,19 +13,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: Configuration @TestState var dependencies: TestDependencies! = TestDependencies() - @TestState(singleton: .storage, in: dependencies) var mockStorage: Storage! = SynchronousStorage( - customWriter: try! DatabaseQueue(), - migrationTargets: [ - SNUtilitiesKit.self, - SNMessagingKit.self - ], - using: dependencies, - initialData: { db in - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.edPublicKey)).insert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).insert(db) + @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: dependencies) + @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( + initialSetup: { cache in + cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) } ) - @TestState(singleton: .crypto, in: dependencies) var crypto: Crypto! = Crypto(using: .any) // MARK: - Crypto for OpenGroupAPI describe("Crypto for OpenGroupAPI") { @@ -187,17 +180,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { context("when encrypting with the session blinding protocol") { // MARK: ---- can encrypt for a blind15 recipient correctly it("can encrypt for a blind15 recipient correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -206,17 +195,13 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- can encrypt for a blind25 recipient correctly it("can encrypt for a blind25 recipient correctly") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) // Note: A Nonce is used for this so we can't compare the exact value when not mocked expect(result).toNot(beNil()) @@ -225,60 +210,45 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- includes a version at the start of the encrypted value it("includes a version at the start of the encrypted value") { - let result: Data? = mockStorage.read { db in - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result: Data? = try? crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(result?.toHexString().prefix(2)).to(equal("00")) } // MARK: ---- throws an error if the recipient isn't a blinded id it("throws an error if the recipient isn't a blinded id") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "05\(TestConstants.publicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "05\(TestConstants.publicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.encryptionFailed)) + ) } + .to(throwError(MessageSenderError.encryptionFailed)) } // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .ciphertextWithSessionBlindingProtocol( - db, - plaintext: "TestMessage".data(using: .utf8)!, - recipientBlindedId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .ciphertextWithSessionBlindingProtocol( + plaintext: "TestMessage".data(using: .utf8)!, + recipientBlindedId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } } @@ -286,21 +256,17 @@ class CryptoOpenGroupAPISpec: QuickSpec { context("when decrypting with the session blinding protocol") { // MARK: ---- can decrypt a blind15 message correctly it("can decrypt a blind15 message correctly") { - let result = mockStorage.read { db in - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "AMuM6E07xyYzN1/gP64v9TelMjkylHsFZznTzE7rDIykIHBHKbdkLnXo4Q1iVWdD" + - "ct9F9YqIsRsqmdLl1t6nfQtWoiUSkjBChvg3J61f7rpS3/A+" - )!, - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result = try? crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "AMuM6E07xyYzN1/gP64v9TelMjkylHsFZznTzE7rDIykIHBHKbdkLnXo4Q1iVWdD" + + "ct9F9YqIsRsqmdLl1t6nfQtWoiUSkjBChvg3J61f7rpS3/A+" + )!, + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex).to(equal("05\(TestConstants.publicKey)")) @@ -308,21 +274,17 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- can decrypt a blind25 message correctly it("can decrypt a blind25 message correctly") { - let result = mockStorage.read { db in - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "ALLcu/jtQsel6HewKdRCsRYXrQl7r60Oz2SX/DKmjCRo4mO2yqMx2+oGwm39n6+p" + - "6dK1n+UWPnm4qGRiN6BvZ+xwNsBruPgyW1EV9i8AcEO0P/1X" - )!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + let result = try? crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "ALLcu/jtQsel6HewKdRCsRYXrQl7r60Oz2SX/DKmjCRo4mO2yqMx2+oGwm39n6+p" + + "6dK1n+UWPnm4qGRiN6BvZ+xwNsBruPgyW1EV9i8AcEO0P/1X" + )!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } + ) expect(String(data: (result?.plaintext ?? Data()), encoding: .utf8)).to(equal("TestMessage")) expect(result?.senderSessionIdHex).to(equal("05\(TestConstants.publicKey)")) @@ -330,114 +292,91 @@ class CryptoOpenGroupAPISpec: QuickSpec { // MARK: ---- throws an error if there is no ed25519 keyPair it("throws an error if there is no ed25519 keyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data( - base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + - "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + - "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" - )!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data( + base64Encoded: "SRP0eBUWh4ez6ppWjUs5/Wph5fhnPRgB5zsWWnTz+FBAw/YI3oS2pDpIfyetMTbU" + + "sFMhE5G4PbRtQFey1hsxLl221Qivc3ayaX2Mm/X89Dl8e45BC+Lb/KU9EdesxIK4pVgYXs9XrMtX3v8" + + "dt0eBaXneOBfr7qB8pHwwMZjtkOu1ED07T9nszgbWabBphUfWXe2U9K3PTRisSCI=" + )!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageSenderError.noUserED25519KeyPair)) + ) } + .to(throwError(MessageSenderError.noUserED25519KeyPair)) } // MARK: ---- throws an error if the data is too short it("throws an error if the data is too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: Data([1, 2, 3]), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: Data([1, 2, 3]), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if the data version is not 0 it("throws an error if the data version is not 0") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: ( - Data([1]) + - "TestMessage".data(using: .utf8)! + - Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! - ), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: ( + Data([1]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if it cannot decrypt the data it("throws an error if it cannot decrypt the data") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: "RandomData".data(using: .utf8)!, - senderId: "25\(TestConstants.blind25PublicKey)", - recipientId: "25\(TestConstants.blind25PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: "RandomData".data(using: .utf8)!, + senderId: "25\(TestConstants.blind25PublicKey)", + recipientId: "25\(TestConstants.blind25PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } // MARK: ---- throws an error if the inner bytes are too short it("throws an error if the inner bytes are too short") { - mockStorage.read { db in - expect { - try crypto.tryGenerate( - .plaintextWithSessionBlindingProtocol( - db, - ciphertext: ( - Data([0]) + - "TestMessage".data(using: .utf8)! + - Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! - ), - senderId: "15\(TestConstants.blind15PublicKey)", - recipientId: "15\(TestConstants.blind15PublicKey)", - serverPublicKey: TestConstants.serverPublicKey, - using: dependencies - ) + expect { + try crypto.tryGenerate( + .plaintextWithSessionBlindingProtocol( + ciphertext: ( + Data([0]) + + "TestMessage".data(using: .utf8)! + + Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")! + ), + senderId: "15\(TestConstants.blind15PublicKey)", + recipientId: "15\(TestConstants.blind15PublicKey)", + serverPublicKey: TestConstants.serverPublicKey ) - } - .to(throwError(MessageReceiverError.decryptionFailed)) + ) } + .to(throwError(MessageReceiverError.decryptionFailed)) } } } diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift index 87f362c45c..d5507c7ef9 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupAPISpec.swift @@ -78,6 +78,23 @@ class OpenGroupAPISpec: QuickSpec { crypto .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + crypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + } + ) + @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( + initialSetup: { cache in + cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) @TestState var disposables: [AnyCancellable]! = [] @@ -834,12 +851,9 @@ class OpenGroupAPISpec: QuickSpec { expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no ed key pair key - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is no ed25519SecretKey + it("fails to sign if there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) var preparationError: Error? let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in @@ -1125,12 +1139,9 @@ class OpenGroupAPISpec: QuickSpec { expect(preparedRequest).to(beNil()) } - // MARK: ------ fails to sign if there is no ed key pair key - it("fails to sign if there is no ed key pair key") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + // MARK: ------ fails to sign if there is ed25519SecretKey + it("fails to sign if there is ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) var preparationError: Error? let preparedRequest: Network.PreparedRequest? = mockStorage.read { db in @@ -1719,12 +1730,9 @@ class OpenGroupAPISpec: QuickSpec { expect(preparedRequest).to(beNil()) } - // MARK: ---- fails when there is no userEdKeyPair - it("fails when there is no userEdKeyPair") { - mockStorage.write { db in - _ = try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - _ = try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } + // MARK: ---- fails when there is no ed25519SecretKey + it("fails when there is no ed25519SecretKey") { + mockGeneralCache.when { $0.ed25519SecretKey }.thenReturn([]) var preparationError: Error? let preparedRequest: Network.PreparedRequest<[OpenGroupAPI.Room]>? = mockStorage.read { db in diff --git a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift index 9edde76359..0e90fe9c92 100644 --- a/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift +++ b/SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift @@ -178,6 +178,17 @@ class OpenGroupManagerSpec: QuickSpec { crypto .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + crypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + crypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } + .thenReturn(Data([1, 2, 3])) } ) @TestState(defaults: .standard, in: dependencies) var mockUserDefaults: MockUserDefaults! = MockUserDefaults( @@ -194,8 +205,15 @@ class OpenGroupManagerSpec: QuickSpec { @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) + @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( + initialSetup: { $0.defaultInitialSetup() } + ) @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( initialSetup: { cache in cache.when { $0.pendingChanges }.thenReturn([]) @@ -219,6 +237,39 @@ class OpenGroupManagerSpec: QuickSpec { cache.when { $0.stopAndRemoveAllPollers() }.thenReturn(()) } ) + @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( + initialSetup: { keychain in + keychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) + } + ) + @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( + initialSetup: { fileManager in + fileManager.when { $0.appSharedDataDirectoryPath }.thenReturn("/test") + fileManager + .when { try $0.ensureDirectoryExists(at: .any, fileProtectionType: .any) } + .thenReturn(()) + fileManager + .when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) } + .thenReturn(()) + fileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) + fileManager.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") + fileManager.when { try $0.removeItem(atPath: .any) }.thenReturn(()) + fileManager + .when { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .thenReturn(true) + fileManager.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) + } + ) @TestState var userGroupsConf: UnsafeMutablePointer! @TestState var userGroupsInitResult: Int32! = { var secretKey: [UInt8] = Array(Data(hex: TestConstants.edSecretKey)) @@ -1919,12 +1970,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2063,12 +2112,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2221,12 +2268,10 @@ class OpenGroupManagerSpec: QuickSpec { .when { $0.generate( .plaintextWithSessionBlindingProtocol( - .any, ciphertext: .any, senderId: .any, recipientId: .any, - serverPublicKey: .any, - using: .any + serverPublicKey: .any ) ) } @@ -2301,29 +2346,31 @@ class OpenGroupManagerSpec: QuickSpec { } } - // MARK: ---- uses an empty set for moderators by default - it("uses an empty set for moderators by default") { + // MARK: ---- has no moderators by default + it("has no moderators by default") { expect( mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) } - // MARK: ---- uses an empty set for admins by default - it("uses an empty set for admins by default") { + // MARK: ----has no admins by default + it("has no admins by default") { expect( mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) @@ -2334,7 +2381,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .moderator, roleStatus: .accepted, isHidden: false @@ -2345,9 +2392,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2358,7 +2406,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .admin, roleStatus: .accepted, isHidden: false @@ -2369,9 +2417,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2382,7 +2431,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .moderator, roleStatus: .accepted, isHidden: true @@ -2393,9 +2442,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2406,7 +2456,7 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(TestConstants.publicKey)", + profileId: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", role: .admin, roleStatus: .accepted, isHidden: true @@ -2417,9 +2467,10 @@ class OpenGroupManagerSpec: QuickSpec { mockStorage.read { db in openGroupManager.isUserModeratorOrAdmin( db, - publicKey: "05\(TestConstants.publicKey)", + publicKey: "05\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beTrue()) @@ -2433,82 +2484,24 @@ class OpenGroupManagerSpec: QuickSpec { db, publicKey: "InvalidValue", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) } ).to(beFalse()) } - // MARK: ---- and the key is a standard session id - context("and the key is a standard session id") { - // MARK: ------ returns false if the key is not the users session id - it("returns false if the key is not the users session id") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns true if the key is the current users and the users unblinded id is a moderator or admin - it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "00\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "05\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - - // MARK: ------ returns true if the key is the current users and the users blinded id is a moderator or admin - it("returns true if the key is the current users and the users blinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) + // MARK: ---- and the key belongs to the current user + context("and the key belongs to the current user") { + // MARK: ------ matches a blinded key + it("matches a blinded key ") { mockStorage.write { db in try GroupMember( groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(otherKey)", - role: .moderator, + profileId: "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))", + role: .admin, roleStatus: .accepted, - isHidden: false + isHidden: true ).insert(db) } @@ -2518,264 +2511,32 @@ class OpenGroupManagerSpec: QuickSpec { db, publicKey: "05\(TestConstants.publicKey)", for: "testRoom", - on: "http://127.0.0.1" + on: "http://127.0.0.1", + currentUserSessionIds: [ + "05\(TestConstants.publicKey)", + "15\(TestConstants.publicKey.replacingOccurrences(of: "1", with: "2"))" + ] ) } ).to(beTrue()) } - } - - // MARK: ---- and the key is unblinded - context("and the key is unblinded") { - // MARK: ------ returns false if unable to retrieve the user ed25519 key - it("returns false if unable to retrieve the user ed25519 key") { - mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if the key is not the users unblinded id - it("returns false if the key is not the users unblinded id") { - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - // MARK: ------ returns true if the key is the current users and the users session id is a moderator or admin - it("returns true if the key is the current users and the users session id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockGeneralCache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: otherKey)) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - - // MARK: ------ returns true if the key is the current users and the users blinded id is a moderator or admin - it("returns true if the key is the current users and the users blinded id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "15\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "00\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - } - - // MARK: ---- and the key is blinded - context("and the key is blinded") { - // MARK: ------ returns false if unable to retrieve the user ed25519 key - it("returns false if unable to retrieve the user ed25519 key") { - mockStorage.write { db in - try Identity.filter(id: .ed25519PublicKey).deleteAll(db) - try Identity.filter(id: .ed25519SecretKey).deleteAll(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if unable generate a blinded key - it("returns false if unable generate a blinded key") { - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn(nil) - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns false if the key is not the users blinded id - it("returns false if the key is not the users blinded id") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: otherKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beFalse()) - } - - // MARK: ------ returns true if the key is the current users and the users session id is a moderator or admin - it("returns true if the key is the current users and the users session id is a moderator or admin") { - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - mockGeneralCache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: otherKey)) - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) - ) - mockStorage.write { db in - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "05\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) - } - - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) - } - - // MARK: ------ returns true if the key is the current users and the users unblinded id is a moderator or admin - it("returns true if the key is the current users and the users unblinded id is a moderator or admin") { - mockCrypto - .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } - .thenReturn( - KeyPair( - publicKey: Data(hex: TestConstants.publicKey).bytes, - secretKey: Data(hex: TestConstants.edSecretKey).bytes - ) + // MARK: ------ generates and unblinded key if the key belongs to the current user + it("generates and unblinded key if the key belongs to the current user") { + mockGeneralCache.when { $0.ed25519Seed }.thenReturn([4, 5, 6]) + mockStorage.read { db in + openGroupManager.isUserModeratorOrAdmin( + db, + publicKey: "05\(TestConstants.publicKey)", + for: "testRoom", + on: "http://127.0.0.1", + currentUserSessionIds: ["05\(TestConstants.publicKey)"] ) - mockStorage.write { db in - let otherKey: String = TestConstants.publicKey.replacingOccurrences(of: "7", with: "6") - - try GroupMember( - groupId: OpenGroup.idFor(roomToken: "testRoom", server: "http://127.0.0.1"), - profileId: "00\(otherKey)", - role: .moderator, - roleStatus: .accepted, - isHidden: false - ).insert(db) - - try Identity(variant: .x25519PublicKey, data: Data(hex: TestConstants.publicKey)).upsert(db) - try Identity(variant: .x25519PrivateKey, data: Data(hex: TestConstants.privateKey)).upsert(db) - try Identity(variant: .ed25519PublicKey, data: Data(hex: otherKey)).upsert(db) - try Identity(variant: .ed25519SecretKey, data: Data(hex: TestConstants.edSecretKey)).upsert(db) } - expect( - mockStorage.read { db in - openGroupManager.isUserModeratorOrAdmin( - db, - publicKey: "15\(TestConstants.publicKey)", - for: "testRoom", - on: "http://127.0.0.1" - ) - } - ).to(beTrue()) + expect(mockCrypto).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.generate(.ed25519KeyPair(seed: [4, 5, 6])) + }) } } } diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift index 270524aa42..2e88690a1c 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageReceiverGroupsSpec.swift @@ -129,14 +129,46 @@ class MessageReceiverGroupsSpec: QuickSpec { ) } .thenReturn(()) + keychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) keychain .when { try $0.data(forKey: .pushNotificationEncryptionKey) } .thenReturn(Data((0.. Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .then { args, untrackedArgs in - let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(userGroupsConfig) - case .convoInfoVolatile: try? callback?(convoInfoVolatileConfig) - case .groupInfo: try? callback?(groupInfoConfig) - case .groupMembers: try? callback?(groupMembersConfig) - case .groupKeys: try? callback?(groupKeysConfig) - default: break - } - } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache - .when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + initialSetup: { + $0.defaultInitialSetup( + configs: [ + .userGroups: userGroupsConfig, + .convoInfoVolatile: convoInfoVolatileConfig, + .groupInfo: groupInfoConfig, + .groupMembers: groupMembersConfig, + .groupKeys: groupKeysConfig + ] + ) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( @@ -260,7 +247,18 @@ class MessageReceiverGroupsSpec: QuickSpec { @TestState(singleton: .notificationsManager, in: dependencies) var mockNotificationsManager: MockNotificationsManager! = MockNotificationsManager( initialSetup: { notificationsManager in notificationsManager - .when { $0.notifyUser(.any, for: .any, in: .any, applicationState: .any) } + .when { $0.notificationUserInfo(threadId: .any, threadVariant: .any) } + .thenReturn([:]) + notificationsManager + .when { $0.notificationShouldPlaySound(applicationState: .any) } + .thenReturn(false) + notificationsManager + .when { + $0.addNotificationRequest( + content: .any, + notificationSettings: .any + ) + } .thenReturn(()) notificationsManager .when { $0.cancelNotifications(identifiers: .any) } @@ -660,43 +658,23 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNotificationsManager) .to(call(.exactly(times: 1), matchingParameters: .all) { notificationsManager in - notificationsManager.notifyUser( - .any, - for: Interaction( - id: 1, - serverHash: nil, - messageUuid: nil, + notificationsManager.addNotificationRequest( + content: NotificationContent( threadId: groupId.hexString, - authorId: "051111111111111111111111111111111" + "111111111111111111111111111111111", - variant: .infoGroupInfoInvited, - body: ClosedGroup.MessageInfo - .invited("0511...1111", "TestGroup") - .infoString(using: dependencies), - timestampMs: 1234567890000, - receivedAtTimestampMs: 1234567890000, - wasRead: false, - hasMention: false, - expiresInSeconds: nil, - expiresStartedAtMs: nil, - linkPreviewUrl: nil, - openGroupServerMessageId: nil, - openGroupWhisper: false, - openGroupWhisperMods: false, - openGroupWhisperTo: nil, - state: .sent, - recipientReadTimestampMs: nil, - mostRecentFailureText: nil, - transientDependencies: EquatableIgnoring(value: dependencies) + threadVariant: .group, + identifier: "\(groupId.hexString)-1", + category: .incomingMessage, + title: "notificationsIosGroup".localized(), + body: "messageNewYouveGot".localized(), + sound: .defaultNotificationSound, + applicationState: .active ), - in: SessionThread( - id: groupId.hexString, - variant: .group, - creationDateTimestamp: 1234567890, - shouldBeVisible: true, - isDraft: false, - using: dependencies - ), - applicationState: .active + notificationSettings: Preferences.NotificationSettings( + mode: .all, + previewType: .nameAndPreview, + sound: .defaultNotificationSound, + mutedUntil: nil + ) ) }) } @@ -822,11 +800,9 @@ class MessageReceiverGroupsSpec: QuickSpec { expect(mockNotificationsManager) .toNot(call { notificationsManager in - notificationsManager.notifyUser( - .any, - for: .any, - in: .any, - applicationState: .any + notificationsManager.addNotificationRequest( + content: .any, + notificationSettings: .any ) }) } @@ -1039,6 +1015,16 @@ class MessageReceiverGroupsSpec: QuickSpec { groups_members_set(groupMembersConf, &member) mockStorage.write { db in + try Contact( + id: "051111111111111111111111111111111111111111111111111111111111111111", + isTrusted: true, + isApproved: true, + isBlocked: false, + lastKnownClientVersion: nil, + didApproveMe: true, + hasBeenBlocked: false, + using: dependencies + ).insert(db) try SessionThread.upsert( db, id: groupId.hexString, @@ -1099,8 +1085,12 @@ class MessageReceiverGroupsSpec: QuickSpec { ) } - expect(LibSession.isAdmin(groupSessionId: groupId, using: dependencies)) - .to(beTrue()) + expect(mockLibSessionCache).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.loadAdminKey( + groupIdentitySeed: groupSeed, + groupSessionId: SessionId(.group, publicKey: [1, 2, 3]) + ) + }) } // MARK: ---- replaces the memberAuthData with the admin key in the database diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift index e12d8bb998..d63b05b4c7 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderGroupsSpec.swift @@ -128,7 +128,7 @@ class MessageSenderGroupsSpec: QuickSpec { .when { $0.generate(.uuid()) } .thenReturn(UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) crypto - .when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any, using: .any)) } + .when { $0.generate(.encryptedDataDisplayPicture(data: .any, key: .any)) } .thenReturn(TestConstants.validImageData) crypto .when { $0.generate(.ciphertextForGroupMessage(groupSessionId: .any, message: .any)) } @@ -146,6 +146,17 @@ class MessageSenderGroupsSpec: QuickSpec { ) } .thenReturn(()) + keychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) keychain .when { try $0.data(forKey: .pushNotificationEncryptionKey) } .thenReturn(Data((0.. Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .then { args, untrackedArgs in - let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void) - - switch args[test: 0] as? ConfigDump.Variant { - case .userGroups: try? callback?(userGroupsConfig) - case .groupInfo: try? callback?(groupInfoConfig) - case .groupMembers: try? callback?(groupMembersConfig) - case .groupKeys: try? callback?(groupKeysConfig) - default: break - } - } - .thenReturn(()) - cache - .when { - try $0.createDumpMarkingAsPushed( - data: .any, - sentTimestamp: .any, - swarmPublicKey: .any - ) - } - .thenReturn([]) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) } ) @TestState(cache: .snodeAPI, in: dependencies) var mockSnodeAPICache: MockSnodeAPICache! = MockSnodeAPICache( @@ -285,7 +249,7 @@ class MessageSenderGroupsSpec: QuickSpec { context("when creating a group") { beforeEach { mockLibSessionCache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } + .when { try $0.pendingChanges(swarmPublicKey: .any) } .thenReturn(LibSession.PendingChanges()) } @@ -454,7 +418,7 @@ class MessageSenderGroupsSpec: QuickSpec { // MARK: ---- syncs the group configuration messages it("syncs the group configuration messages") { mockLibSessionCache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } + .when { try $0.pendingChanges(swarmPublicKey: .any) } .thenReturn( LibSession.PendingChanges( pushData: [ diff --git a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift index 16e811db38..7d0a1d3717 100644 --- a/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/MessageSenderSpec.swift @@ -35,11 +35,23 @@ class MessageSenderSpec: QuickSpec { crypto .when { $0.generate(.randomBytes(24)) } .thenReturn(Array(Data(base64Encoded: "pbTUizreT0sqJ2R2LloseQDyVL2RYztD")!)) + crypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) } ) @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) @@ -50,7 +62,7 @@ class MessageSenderSpec: QuickSpec { beforeEach { mockCrypto .when { - $0.generate(.ciphertextWithSessionProtocol(.any, plaintext: .any, destination: .any, using: .any)) + $0.generate(.ciphertextWithSessionProtocol(plaintext: .any, destination: .any)) } .thenReturn(Data([1, 2, 3])) mockCrypto diff --git a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift index ebbbaae296..c8b0864ef4 100644 --- a/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift +++ b/SessionMessagingKitTests/Sending & Receiving/Pollers/CommunityPollerSpec.swift @@ -78,6 +78,10 @@ class CommunityPollerSpec: QuickSpec { @TestState(cache: .general, in: dependencies) var mockGeneralCache: MockGeneralCache! = MockGeneralCache( initialSetup: { cache in cache.when { $0.sessionId }.thenReturn(SessionId(.standard, hex: TestConstants.publicKey)) + cache.when { $0.ed25519SecretKey }.thenReturn(Array(Data(hex: TestConstants.edSecretKey))) + cache + .when { $0.ed25519Seed } + .thenReturn(Array(Array(Data(hex: TestConstants.edSecretKey)).prefix(upTo: 32))) } ) @TestState(cache: .openGroupManager, in: dependencies) var mockOGMCache: MockOGMCache! = MockOGMCache( @@ -86,6 +90,35 @@ class CommunityPollerSpec: QuickSpec { cache.when { $0.getLastSuccessfulCommunityPollTimestamp() }.thenReturn(0) } ) + @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( + initialSetup: { crypto in + crypto + .when { $0.generate(.hash(message: .any, key: .any, length: .any)) } + .thenReturn([]) + crypto + .when { $0.generate(.blinded15KeyPair(serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn( + KeyPair( + publicKey: Data(hex: TestConstants.publicKey).bytes, + secretKey: Data(hex: TestConstants.edSecretKey).bytes + ) + ) + crypto + .when { $0.generate(.signatureBlind15(message: .any, serverPublicKey: .any, ed25519SecretKey: .any)) } + .thenReturn("TestSogsSignature".bytes) + crypto + .when { $0.generate(.randomBytes(16)) } + .thenReturn(Array(Data(base64Encoded: "pK6YRtQApl4NhECGizF0Cg==")!)) + crypto + .when { $0.generate(.ed25519KeyPair(seed: .any)) } + .thenReturn( + KeyPair( + publicKey: Array(Data(hex: TestConstants.edPublicKey)), + secretKey: Array(Data(hex: TestConstants.edSecretKey)) + ) + ) + } + ) @TestState var cache: CommunityPoller.Cache! = CommunityPoller.Cache(using: dependencies) // MARK: - a CommunityPollerCache diff --git a/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift new file mode 100644 index 0000000000..cad88c39e5 --- /dev/null +++ b/SessionMessagingKitTests/Utilities/ExtensionHelperSpec.swift @@ -0,0 +1,444 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +import Quick +import Nimble + +@testable import SessionMessagingKit + +class ExtesnionHelperSpec: QuickSpec { + override class func spec() { + // MARK: Configuration + + @TestState var dependencies: TestDependencies! = TestDependencies() + @TestState(singleton: .extensionHelper, in: dependencies) var extensionHelper: ExtensionHelper! = ExtensionHelper(using: dependencies) + @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( + initialSetup: { crypto in + crypto.when { $0.generate(.hash(message: .any)) }.thenReturn([1, 2, 3]) + crypto + .when { $0.generate(.ciphertextWithXChaCha20(plaintext: .any, encKey: .any)) } + .thenReturn(Data([4, 5, 6])) + } + ) + @TestState(singleton: .fileManager, in: dependencies) var mockFileManager: MockFileManager! = MockFileManager( + initialSetup: { fileManager in + fileManager.when { $0.appSharedDataDirectoryPath }.thenReturn("/test") + fileManager + .when { try $0.ensureDirectoryExists(at: .any, fileProtectionType: .any) } + .thenReturn(()) + fileManager + .when { try $0.protectFileOrFolder(at: .any, fileProtectionType: .any) } + .thenReturn(()) + fileManager.when { $0.fileExists(atPath: .any) }.thenReturn(true) + fileManager.when { $0.temporaryFilePath(fileExtension: .any) }.thenReturn("tmpFile") + fileManager.when { try $0.removeItem(atPath: .any) }.thenReturn(()) + fileManager + .when { $0.createFile(atPath: .any, contents: .any, attributes: .any) } + .thenReturn(true) + fileManager.when { try $0.moveItem(atPath: .any, toPath: .any) }.thenReturn(()) + fileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(true) + } + ) + @TestState(singleton: .keychain, in: dependencies) var mockKeychain: MockKeychain! = MockKeychain( + initialSetup: { keychain in + keychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenReturn(Data([1, 2, 3])) + } + ) + + // MARK: - an ExtensionHelper - File Management + describe("an ExtensionHelper") { + // MARK: -- when writing an encrypted file + context("when writing an encrypted file") { + // MARK: ---- ensures the write directory exists + it("ensures the write directory exists") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.ensureDirectoryExists(at: "/test/extensionCache/dedupe/010203") + }) + } + + // MARK: ---- protects the write directory + it("protects the write directory") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.protectFileOrFolder(at: "/test/extensionCache/dedupe/010203") + }) + } + + // MARK: ---- generates a temporary file path + it("generates a temporary file path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.temporaryFilePath(fileExtension: nil) + }) + } + + // MARK: ---- writes the encrypted data to the temporary file path + it("writes the encrypted data to the temporary file path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + $0.createFile(atPath: "tmpFile", contents: Data([4, 5, 6])) + }) + } + + // MARK: ---- removes any existing file from the destination path + it("removes any existing file from the destination path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.removeItem(atPath: "/test/extensionCache/dedupe/010203/010203") + }) + } + + // MARK: ---- moves the temporary file to the destination path + it("moves the temporary file to the destination path") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/dedupe/010203/010203" + ) + }) + } + + // MARK: ---- throws when failing to retrieve the encryption key + it("throws when failing to retrieve the encryption key") { + mockKeychain + .when { + try $0.getOrGenerateEncryptionKey( + forKey: .any, + length: .any, + cat: .any, + legacyKey: .any, + legacyService: .any + ) + } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.noEncryptionKey)) + } + + // MARK: ---- throws encryption errors + it("throws encryption errors") { + mockCrypto + .when { + try $0.tryGenerate( + .ciphertextWithXChaCha20(plaintext: .any, encKey: .any) + ) + } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + + // MARK: ---- throws when it fails to write to disk + it("throws when it fails to write to disk") { + mockFileManager + .when { $0.createFile(atPath: .any, contents: .any) } + .thenReturn(false) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.failedToWriteToFile)) + } + + // MARK: ---- does not throw when attempting to remove an existing item at the destination fails + it("does not throw when attempting to remove an existing item at the destination fails") { + mockFileManager + .when { try $0.removeItem(atPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.toNot(throwError(TestError.mock)) + } + + // MARK: ---- throws when it fails to move the temp file to the final location + it("throws when it fails to move the temp file to the final location") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + } + + // MARK: - an ExtensionHelper - Deduping + describe("an ExtensionHelper") { + // MARK: -- when checking whether a single dedupe record exists + context("when checking whether a single dedupe record exists") { + // MARK: ---- returns true when at least one record exists + it("returns true when at least one record exists") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beTrue()) + } + + // MARK: ---- returns false when a record does not exist + it("returns false when a record does not exist") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(true) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beFalse()) + } + + // MARK: ---- returns false when failing to generate a hash + it("returns false when failing to generate a hash") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect(extensionHelper.hasAtLeastOneDedupeRecord(threadId: "threadId")).to(beFalse()) + } + } + + // MARK: -- when checking for dedupe records + context("when checking for dedupe records") { + // MARK: ---- returns true when a record exists + it("returns true when a record exists") { + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beTrue()) + } + + // MARK: ---- returns false when a record does not exist + it("returns false when a record does not exist") { + mockFileManager.when { $0.fileExists(atPath: .any) }.thenReturn(false) + + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beFalse()) + } + + // MARK: ---- returns false when failing to generate a hash + it("returns false when failing to generate a hash") { + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect(extensionHelper.dedupeRecordExists( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + )).to(beFalse()) + } + } + + // MARK: -- when creating dedupe records + context("when creating dedupe records") { + // MARK: ---- writes the file successfully + it("writes the file successfully") { + try? extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.moveItem( + atPath: "tmpFile", + toPath: "/test/extensionCache/dedupe/010203/010203" + ) + }) + } + + // MARK: ---- throws when failing to generate a hash + it("throws when failing to generate a hash") { + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(ExtensionHelperError.failedToStoreDedupeRecord)) + } + + // MARK: ---- throws when failing to write the file + it("throws when failing to write the file") { + mockFileManager + .when { try $0.moveItem(atPath: .any, toPath: .any) } + .thenThrow(TestError.mock) + + expect { + try extensionHelper.createDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: -- when removing dedupe records + context("when removing dedupe records") { + // MARK: ---- removes the file successfully + it("removes the file successfully") { + try? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/dedupe/010203/010203") + }) + } + + // MARK: ---- removes the parent directory if it is empty + it("removes the parent directory if it is empty") { + try? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/dedupe/010203") + }) + } + + // MARK: ---- leaves the parent directory if not empty + it("leaves the parent directory if not empty") { + mockFileManager.when { $0.isDirectoryEmpty(atPath: .any) }.thenReturn(false) + + try? extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + + expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/dedupe/010203") + }) + } + + // MARK: ---- does nothing when failing to generate a hash + it("does nothing when failing to generate a hash") { + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect { + try extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.toNot(throwError(ExtensionHelperError.failedToStoreDedupeRecord)) + expect(mockFileManager).toNot(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/dedupe/010203/010203") + }) + } + + // MARK: ---- throws when failing to remove the file + it("throws when failing to remove the file") { + mockFileManager.when { try $0.removeItem(atPath: .any) }.thenThrow(TestError.mock) + + expect { + try extensionHelper.removeDedupeRecord( + threadId: "threadId", + uniqueIdentifier: "uniqueId" + ) + }.to(throwError(TestError.mock)) + } + } + + // MARK: -- when removing all records + context("when removing all records") { + // MARK: ---- removes all dedupe records + it("removes all dedupe records") { + extensionHelper.deleteAllDedupeRecords() + + expect(mockFileManager).to(call(.exactly(times: 1), matchingParameters: .all) { + try? $0.removeItem(atPath: "/test/extensionCache/dedupe") + }) + } + } + } + + // MARK: - an ExtensionHelper - Config Dumps + describe("an ExtensionHelper") { + // MARK: -- when retrieving the last updated timestamp + context("when retrieving the last updated timestamp") { + // MARK: ---- returns the timestamp + it("returns the timestamp") { + mockFileManager + .when { try $0.attributesOfItem(atPath: .any) } + .thenReturn([.modificationDate: Date(timeIntervalSince1970: 1234567890)]) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(1234567890)) + } + + // MARK: ---- returns zero when it fails to generate a hash + it("returns zero when it fails to generate a hash") { + mockCrypto.when { $0.generate(.hash(message: .any)) }.thenReturn(nil) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(0)) + } + + // MARK: ---- throws when failing to retrieve file metadata + it("throws when failing to retrieve file metadata") { + mockFileManager.when { try $0.attributesOfItem(atPath: .any) }.thenReturn(nil) + + expect(extensionHelper.lastUpdatedTimestamp( + for: SessionId(.standard, hex: TestConstants.publicKey), + variant: .userProfile + )).to(equal(0)) + } + } + } + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift index 21f7ec48ae..11c3098013 100644 --- a/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift +++ b/SessionMessagingKitTests/_TestUtilities/CommonSMKMockExtensions.swift @@ -25,6 +25,15 @@ extension LibSession.CacheBehaviour: Mocked { static var mock: LibSession.CacheBehaviour = .skipAutomaticConfigSync } +extension LibSession.OpenGroupUrlInfo: Mocked { + static var mock: LibSession.OpenGroupUrlInfo = LibSession.OpenGroupUrlInfo( + threadId: .mock, + server: .mock, + roomToken: .mock, + publicKey: .mock + ) +} + extension SessionThread: Mocked { static var mock: SessionThread = SessionThread( id: .mock, @@ -46,6 +55,10 @@ extension SessionThread.Variant: Mocked { static var mock: SessionThread.Variant = .contact } +extension Interaction.Variant: Mocked { + static var mock: Interaction.Variant = .standardIncoming +} + extension Interaction: Mocked { static var mock: Interaction = Interaction( id: 123456, @@ -53,7 +66,7 @@ extension Interaction: Mocked { messageUuid: nil, threadId: .mock, authorId: .mock, - variant: .standardIncoming, + variant: .mock, body: .mock, timestampMs: 1234567890, receivedAtTimestampMs: 1234567890, @@ -72,3 +85,25 @@ extension Interaction: Mocked { transientDependencies: nil ) } + +extension KeychainStorage.DataKey: Mocked { + static var mock: KeychainStorage.DataKey = .dbCipherKeySpec +} + +extension NotificationCategory: Mocked { + static var mock: NotificationCategory = .incomingMessage +} + +extension NotificationContent: Mocked { + static var mock: NotificationContent = NotificationContent( + threadId: .mock, + threadVariant: .mock, + identifier: .mock, + category: .mock, + applicationState: .any + ) +} + +extension Preferences.NotificationSettings: Mocked { + static var mock: Preferences.NotificationSettings = .defaultFor(.mock) +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift new file mode 100644 index 0000000000..c1b2b11b83 --- /dev/null +++ b/SessionMessagingKitTests/_TestUtilities/MockExtensionHelper.swift @@ -0,0 +1,36 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SessionUtilitiesKit + +@testable import SessionMessagingKit + +class MockExtensionHelper: Mock, ExtensionHelperType { + // MARK: - Deduping + + func hasAtLeastOneDedupeRecord(threadId: String) -> Bool { + return mock(args: [threadId]) + } + + func dedupeRecordExists(threadId: String, uniqueIdentifier: String) -> Bool { + return mock(args: [threadId, uniqueIdentifier]) + } + + func createDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + return try mockThrowing(args: [threadId, uniqueIdentifier]) + } + + func removeDedupeRecord(threadId: String, uniqueIdentifier: String) throws { + return try mockThrowing(args: [threadId, uniqueIdentifier]) + } + + func deleteAllDedupeRecords() { + mockNoReturn() + } + + // MARK: - Config Dumps + + func lastUpdatedTimestamp(for sessionId: SessionId, variant: ConfigDump.Variant) -> TimeInterval { + return mock(args: [sessionId, variant]) + } +} diff --git a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift index 56408d066b..dbd05a5fcc 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockLibSessionCache.swift @@ -26,6 +26,13 @@ class MockLibSessionCache: Mock, LibSessionCacheType { mockNoReturn(args: [variant, sessionId, userEd25519KeyPair, groupEd25519SecretKey]) } + func loadAdminKey( + groupIdentitySeed: Data, + groupSessionId: SessionId + ) throws { + try mockThrowingNoReturn(args: [groupIdentitySeed, groupSessionId]) + } + func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { return mock(args: [variant, sessionId]) } @@ -70,8 +77,8 @@ class MockLibSessionCache: Mock, LibSessionCacheType { try mockThrowingNoReturn(args: [variant, sessionId], untrackedArgs: [db, change]) } - func pendingChanges(_ db: Database, swarmPublicKey: String) throws -> LibSession.PendingChanges { - return mock(args: [swarmPublicKey], untrackedArgs: [db]) + func pendingChanges(swarmPublicKey: String) throws -> LibSession.PendingChanges { + return mock(args: [swarmPublicKey]) } func createDumpMarkingAsPushed( @@ -92,6 +99,26 @@ class MockLibSessionCache: Mock, LibSessionCacheType { return mock(args: [swarmPublicKey]) } + func mergeConfigMessages( + swarmPublicKey: String, + messages: [ConfigMessageReceiveJob.Details.MessageInfo], + afterMerge: (SessionId, ConfigDump.Variant, LibSession.Config?, Int64) throws -> Void + ) throws { + try mockThrowingNoReturn(args: [swarmPublicKey, messages]) + + /// **Note:** Since `afterMerge` is non-escaping (and we don't want to change it to be so for the purposes of mocking + /// in unit test) we just call it directly instead of storing in `untrackedArgs` + guard + let expectation: MockFunction = getExpectation(args: [swarmPublicKey, messages]), + expectation.closureCallArgs.count == 4, + let sessionId: SessionId = expectation.closureCallArgs[0] as? SessionId, + let variant: ConfigDump.Variant = expectation.closureCallArgs[1] as? ConfigDump.Variant, + let timestamp: Int64 = expectation.closureCallArgs[3] as? Int64 + else { return } + + try afterMerge(sessionId, variant, expectation.closureCallArgs[2] as? LibSession.Config, timestamp) + } + func handleConfigMessages( _ db: Database, swarmPublicKey: String, @@ -106,25 +133,193 @@ class MockLibSessionCache: Mock, LibSessionCacheType { ) throws { try mockThrowingNoReturn(args: [swarmPublicKey, messages]) } + // MARK: - State Access - // MARK: - Value Access + func canPerformChange( + threadId: String, + threadVariant: SessionThread.Variant, + changeTimestampMs: Int64 + ) -> Bool { + return mock(args: [threadId, threadVariant, changeTimestampMs]) + } - public func pinnedPriority( - _ db: Database, + func conversationInConfig( + threadId: String, + threadVariant: SessionThread.Variant, + visibleOnly: Bool, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Bool { + return mock(args: [threadId, threadVariant, visibleOnly, openGroupUrlInfo]) + } + + func conversationDisplayName( + threadId: String, + threadVariant: SessionThread.Variant, + contactProfile: Profile?, + visibleMessage: VisibleMessage?, + openGroupName: String?, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> String { + return mock(args: [threadId, threadVariant, contactProfile, visibleMessage, openGroupName, openGroupUrlInfo]) + } + + func isMessageRequest( threadId: String, threadVariant: SessionThread.Variant - ) -> Int32? { - return mock(args: [threadId, threadVariant], untrackedArgs: [db]) + ) -> Bool { + return mock(args: [threadId, threadVariant]) } - public func disappearingMessagesConfig( + func pinnedPriority( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Int32 { + return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + } + + func notificationSettings( + threadId: String, + threadVariant: SessionThread.Variant, + openGroupUrlInfo: LibSession.OpenGroupUrlInfo? + ) -> Preferences.NotificationSettings { + return mock(args: [threadId, threadVariant, openGroupUrlInfo]) + } + + func disappearingMessagesConfig( threadId: String, threadVariant: SessionThread.Variant ) -> DisappearingMessagesConfiguration? { return mock(args: [threadId, threadVariant]) } + func isContactBlocked(contactId: String) -> Bool { + return mock(args: [contactId]) + } + + func profile( + threadId: String, + threadVariant: SessionThread.Variant, + contactId: String, + visibleMessage: VisibleMessage? + ) -> Profile? { + return mock(args: [threadId, threadVariant, contactId, visibleMessage]) + } + + func hasCredentials(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + func isAdmin(groupSessionId: SessionId) -> Bool { return mock(args: [groupSessionId]) } + + func wasKickedFromGroup(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + + func groupName(groupSessionId: SessionId) -> String? { + return mock(args: [groupSessionId]) + } + + func groupIsDestroyed(groupSessionId: SessionId) -> Bool { + return mock(args: [groupSessionId]) + } + + func groupDeleteBefore(groupSessionId: SessionId) -> TimeInterval? { + return mock(args: [groupSessionId]) + } + + func groupDeleteAttachmentsBefore(groupSessionId: SessionId) -> TimeInterval? { + return mock(args: [groupSessionId]) + } +} + +// MARK: - Convenience + +extension Mock where T == LibSessionCacheType { + func defaultInitialSetup(configs: [ConfigDump.Variant: LibSession.Config?] = [:]) { + let userSessionId: SessionId = SessionId(.standard, hex: TestConstants.publicKey) + + configs.forEach { key, value in + switch value { + case .none: break + case .some(let config): self.when { $0.config(for: key, sessionId: .any) }.thenReturn(config) + } + } + + self.when { $0.isEmpty }.thenReturn(false) + self.when { $0.userSessionId }.thenReturn(userSessionId) + self.when { $0.setConfig(for: .any, sessionId: .any, to: .any) }.thenReturn(()) + self.when { $0.removeConfigs(for: .any) }.thenReturn(()) + self.when { $0.hasConfig(for: .any, sessionId: .any) }.thenReturn(true) + self + .when { try $0.pendingChanges(swarmPublicKey: .any) } + .thenReturn(LibSession.PendingChanges()) + self.when { $0.configNeedsDump(.any) }.thenReturn(false) + self + .when { try $0.createDump(config: .any, for: .any, sessionId: .any, timestampMs: .any) } + .thenReturn(nil) + self + .when { try $0.withCustomBehaviour(.any, for: .any, variant: .any, change: { }) } + .then { args, untrackedArgs in + let callback: (() throws -> Void)? = (untrackedArgs[test: 0] as? () throws -> Void) + try? callback?() + } + .thenReturn(()) + self + .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } + .then { args, untrackedArgs in + let callback: ((LibSession.Config?) throws -> Void)? = (untrackedArgs[test: 1] as? (LibSession.Config?) throws -> Void) + + switch configs[(args[test: 0] as? ConfigDump.Variant ?? .invalid)] { + case .none: break + case .some(let config): try? callback?(config) + } + } + .thenReturn(()) + self + .when { + try $0.createDumpMarkingAsPushed( + data: .any, + sentTimestamp: .any, + swarmPublicKey: .any + ) + } + .thenReturn([]) + self + .when { + $0.conversationInConfig( + threadId: .any, + threadVariant: .any, + visibleOnly: .any, + openGroupUrlInfo: .any + ) + } + .thenReturn(true) + self + .when { $0.canPerformChange(threadId: .any, threadVariant: .any, changeTimestampMs: .any) } + .thenReturn(true) + self + .when { $0.isMessageRequest(threadId: .any, threadVariant: .any) } + .thenReturn(false) + self + .when { $0.pinnedPriority(threadId: .any, threadVariant: .any, openGroupUrlInfo: .any) } + .thenReturn(LibSession.defaultNewThreadPriority) + self + .when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } + .thenReturn(nil) + self + .when { $0.notificationSettings(threadId: .any, threadVariant: .any, openGroupUrlInfo: .any) } + .thenReturn(.defaultFor(.contact)) + self.when { $0.isContactBlocked(contactId: .any) }.thenReturn(false) + self.when { $0.hasCredentials(groupSessionId: .any) }.thenReturn(true) + self.when { $0.isAdmin(groupSessionId: .any) }.thenReturn(true) + self.when { try $0.loadAdminKey(groupIdentitySeed: .any, groupSessionId: .any) }.thenReturn(()) + self.when { $0.groupName(groupSessionId: .any) }.thenReturn("TestGroupName") + self.when { $0.groupIsDestroyed(groupSessionId: .any) }.thenReturn(false) + self.when { $0.wasKickedFromGroup(groupSessionId: .any) }.thenReturn(false) + self.when { $0.groupDeleteBefore(groupSessionId: .any) }.thenReturn(nil) + self.when { $0.groupDeleteAttachmentsBefore(groupSessionId: .any) }.thenReturn(nil) + } } diff --git a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift index bb083074f8..954f69082c 100644 --- a/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift +++ b/SessionMessagingKitTests/_TestUtilities/MockNotificationsManager.swift @@ -25,37 +25,28 @@ public class MockNotificationsManager: Mock, Notificat return mock() } - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - mockNoReturn(args: [interaction, thread, applicationState], untrackedArgs: [db]) - } - - public func notifyUser( - _ db: Database, - forIncomingCall interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - mockNoReturn(args: [interaction, thread, applicationState], untrackedArgs: [db]) + public func notificationUserInfo( + threadId: String, + threadVariant: SessionThread.Variant + ) -> [String: Any] { + return mock(args: [threadId, threadVariant]) } - public func notifyUser( - _ db: Database, - forReaction reaction: Reaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - mockNoReturn(args: [reaction, thread, applicationState], untrackedArgs: [db]) + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return mock(args: [applicationState]) } public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { mockNoReturn(args: [thread, applicationState], untrackedArgs: [db]) } + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings + ) { + mockNoReturn(args: [content, notificationSettings]) + } + public func cancelNotifications(identifiers: [String]) { mockNoReturn(args: [identifiers]) } diff --git a/SessionNotificationServiceExtension/NSENotificationPresenter.swift b/SessionNotificationServiceExtension/NSENotificationPresenter.swift index db61e29fe9..3cbd9cdadb 100644 --- a/SessionNotificationServiceExtension/NSENotificationPresenter.swift +++ b/SessionNotificationServiceExtension/NSENotificationPresenter.swift @@ -10,7 +10,7 @@ import SessionMessagingKit import SessionUtilitiesKit public class NSENotificationPresenter: NotificationsManagerType { - private let dependencies: Dependencies + public let dependencies: Dependencies private var notifications: [String: UNNotificationRequest] = [:] // MARK: - Initialization @@ -27,265 +27,50 @@ public class NSENotificationPresenter: NotificationsManagerType { return Just(()).eraseToAnyPublisher() } - // MARK: - Presentation + // MARK: - Unique Logic - public func notifyUser( - _ db: Database, - for interaction: Interaction, - in thread: SessionThread, - applicationState: UIApplication.State - ) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // Ensure we should be showing a notification for the thread - guard thread.shouldShowNotification(db, for: interaction, isMessageRequest: isMessageRequest, using: dependencies) else { - Log.info("Ignoring notification because thread reported that we shouldn't show it.") - return - } - - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - let groupName: String = SessionThread.displayName( - threadId: thread.id, - variant: thread.variant, - closedGroupName: (try? thread.closedGroup.fetchOne(db))?.name, - openGroupName: (try? thread.openGroup.fetchOne(db))?.name - ) - var notificationTitle: String = senderName - - if thread.variant == .legacyGroup || thread.variant == .group || thread.variant == .community { - if thread.onlyNotifyForMentions && !interaction.hasMention { - // Ignore PNs if the group is set to only notify for mentions - return - } - - notificationTitle = "notificationsIosGroup" - .put(key: "name", value: senderName) - .put(key: "conversation_name", value: groupName) - .localized() - } - - let snippet: String = (Interaction - .notificationPreviewText(db, interaction: interaction, using: dependencies) - .filteredForDisplay - .nullIfEmpty? - .replacingMentions(for: thread.id, using: dependencies)) - .defaulting(to: "messageNewYouveGot" - .putNumber(1) - .localized() - ) - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue + public func notificationUserInfo(threadId: String, threadVariant: SessionThread.Variant) -> [String: Any] { + return [ + NotificationUserInfoKey.isFromRemote: true, + NotificationUserInfoKey.threadId: threadId, + NotificationUserInfoKey.threadVariantRaw: threadVariant.rawValue ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .defaultPreviewType) - - switch previewType { - case .nameAndPreview: - notificationContent.title = notificationTitle - notificationContent.body = snippet - - case .nameNoPreview: - notificationContent.title = notificationTitle - notificationContent.body = "messageNewYouveGot" - .putNumber(1) - .localized() - - case .noNameNoPreview: - notificationContent.title = Constants.app_name - notificationContent.body = "messageNewYouveGot" - .putNumber(1) - .localized() - } - - // 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 { - notificationContent.title = Constants.app_name - notificationContent.body = "messageRequestsNew".localized() - } - - // Add request (try to group notifications for interactions from open groups) - let identifier: String = Interaction.notificationIdentifier( - for: (interaction.id ?? 0), - threadId: thread.id, - shouldGroupMessagesForThread: (thread.variant == .community) - ) - var trigger: UNNotificationTrigger? - - if thread.variant == .community { - trigger = UNTimeIntervalNotificationTrigger( - timeInterval: Notifications.delayForGroupedNotifications, - repeats: false - ) - - let numberExistingNotifications: Int? = notifications[identifier]? - .content - .userInfo[NotificationServiceExtension.threadNotificationCounter] - .asType(Int.self) - var numberOfNotifications: Int = (numberExistingNotifications ?? 1) - - if numberExistingNotifications != nil { - numberOfNotifications += 1 // Add one for the current notification - - notificationContent.title = (previewType == .noNameNoPreview ? - notificationContent.title : - groupName - ) - notificationContent.body = "messageNewYouveGot" - .putNumber(numberOfNotifications) - .localized() - } - - notificationContent.userInfo[NotificationServiceExtension.threadNotificationCounter] = numberOfNotifications - } - - addNotifcationRequest( - identifier: identifier, - notificationContent: notificationContent, - trigger: trigger - ) } - public func notifyUser(_ db: Database, forIncomingCall interaction: Interaction, in thread: SessionThread, applicationState: UIApplication.State) { - // No call notifications for muted or group threads - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard - interaction.variant == .infoCall, - let infoMessageData: Data = (interaction.body ?? "").data(using: .utf8), - let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode( - CallMessage.MessageInfo.self, - from: infoMessageData - ) - else { return } - - // Only notify missed calls - switch messageInfo.state { - case .missed, .permissionDenied, .permissionDeniedMicrophone: break - default: return - } - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue - ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } - - notificationContent.title = Constants.app_name - notificationContent.body = "" - - let senderName: String = Profile.displayName(db, id: interaction.authorId, threadVariant: thread.variant, using: dependencies) - - switch messageInfo.state { - case .permissionDenied: - notificationContent.body = "callsYouMissedCallPermissions" - .put(key: "name", value: senderName) - .localizedDeformatted() - case .permissionDeniedMicrophone: - notificationContent.body = "callsMissedCallFrom" - .put(key: "name", value: senderName) - .localizedDeformatted() - default: - break - } - - addNotifcationRequest( - identifier: UUID().uuidString, - notificationContent: notificationContent, - trigger: nil - ) + public func notificationShouldPlaySound(applicationState: UIApplication.State) -> Bool { + return true } - public func notifyUser(_ db: Database, forReaction reaction: Reaction, in thread: SessionThread, applicationState: UIApplication.State) { - let isMessageRequest: Bool = SessionThread.isMessageRequest( - db, - threadId: thread.id, - userSessionId: dependencies[cache: .general].sessionId, - includeNonVisible: true - ) - - // No reaction notifications for muted, group threads or message requests - guard Date().timeIntervalSince1970 > (thread.mutedUntilTimestamp ?? 0) else { return } - guard - thread.variant != .legacyGroup && - thread.variant != .group && - thread.variant != .community - else { return } - guard !isMessageRequest else { return } - - let notificationTitle = Profile.displayName(db, id: reaction.authorId, threadVariant: thread.variant, using: dependencies) - var notificationBody = "emojiReactsNotification" - .put(key: "emoji", value: reaction.emoji) - .localized() - - // Title & body - let previewType: Preferences.NotificationPreviewType = db[.preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - - switch previewType { - case .nameAndPreview: break - default: notificationBody = "messageNewYouveGot" - .putNumber(1) - .localized() - } - - let userInfo: [String: Any] = [ - NotificationServiceExtension.isFromRemoteKey: true, - NotificationServiceExtension.threadIdKey: thread.id, - NotificationServiceExtension.threadVariantRaw: thread.variant.rawValue - ] - - let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = userInfo - notificationContent.sound = thread.notificationSound - .defaulting(to: db[.defaultNotificationSound] ?? Preferences.Sound.defaultNotificationSound) - .notificationSound(isQuiet: false) - notificationContent.title = notificationTitle - notificationContent.body = notificationBody - - addNotifcationRequest(identifier: UUID().uuidString, notificationContent: notificationContent, trigger: nil) - } + // MARK: - Presentation public func notifyForFailedSend(_ db: Database, in thread: SessionThread, applicationState: UIApplication.State) { // Not possible in the NotificationServiceExtension } + public func addNotificationRequest( + content: NotificationContent, + notificationSettings: Preferences.NotificationSettings + ) { + let request = UNNotificationRequest( + identifier: content.identifier, + content: content.toMutableContent( + shouldPlaySound: notificationShouldPlaySound(applicationState: content.applicationState) + ), + trigger: nil + ) + + Log.info("Add remote notification request: \(content.identifier)") + let semaphore = DispatchSemaphore(value: 0) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + Log.error("Failed to add notification request '\(content.identifier)' due to error: \(error)") + } + semaphore.signal() + } + semaphore.wait() + Log.info("Finish adding remote notification request '\(content.identifier)") + } + // MARK: - Clearing public func cancelNotifications(identifiers: [String]) { @@ -300,43 +85,3 @@ public class NSENotificationPresenter: NotificationsManagerType { notificationCenter.removeAllDeliveredNotifications() } } - -// MARK: - Convenience -private extension NSENotificationPresenter { - func addNotifcationRequest(identifier: String, notificationContent: UNNotificationContent, trigger: UNNotificationTrigger?) { - let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: trigger) - - Log.info("Add remote notification request: \(identifier)") - let semaphore = DispatchSemaphore(value: 0) - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - Log.error("Failed to add notification request '\(identifier)' due to error: \(error)") - } - semaphore.signal() - } - semaphore.wait() - Log.info("Finish adding remote notification request '\(identifier)") - } -} - -private extension String { - - func replacingMentions(for threadID: String, using dependencies: Dependencies) -> String { - var result = self - let regex = try! NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) - var mentions: [(range: NSRange, publicKey: String)] = [] - var m0 = regex.firstMatch(in: result, options: .withoutAnchoringBounds, range: NSRange(location: 0, length: result.utf16.count)) - while let m1 = m0 { - let publicKey = String((result as NSString).substring(with: m1.range).dropFirst()) // Drop the @ - var matchEnd = m1.range.location + m1.range.length - - if let displayName: String = Profile.displayNameNoFallback(id: publicKey, using: dependencies) { - result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)") // stringlint:ignore - mentions.append((range: NSRange(location: m1.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @ - matchEnd = m1.range.location + displayName.utf16.count - } - m0 = regex.firstMatch(in: result, options: .withoutAnchoringBounds, range: NSRange(location: matchEnd, length: result.utf16.count - matchEnd)) - } - return result - } -} diff --git a/SessionNotificationServiceExtension/NotificationResolution.swift b/SessionNotificationServiceExtension/NotificationResolution.swift index 47803dda19..eaeef68855 100644 --- a/SessionNotificationServiceExtension/NotificationResolution.swift +++ b/SessionNotificationServiceExtension/NotificationResolution.swift @@ -16,16 +16,16 @@ enum NotificationResolution: CustomStringConvertible { case ignoreDueToNonLegacyGroupLegacyNotification case ignoreDueToOutdatedMessage case ignoreDueToRequiresNoNotification + case ignoreDueToMessageRequest case ignoreDueToDuplicateMessage case ignoreDueToContentSize(PushNotificationAPI.NotificationMetadata) case errorTimeout case errorNotReadyForExtensions - case errorNoContentLegacy + case errorLegacyPushNotification case errorDatabaseInvalid case errorDatabaseMigrations(Error) case errorTransactionFailure - case errorLegacyGroupKeysMissing case errorCallFailure case errorNoContent(PushNotificationAPI.NotificationMetadata) case errorProcessing(PushNotificationAPI.ProcessResult) @@ -42,6 +42,7 @@ enum NotificationResolution: CustomStringConvertible { case .ignoreDueToNonLegacyGroupLegacyNotification: return "Ignored: Non-group legacy notification" case .ignoreDueToOutdatedMessage: return "Ignored: Alteady seen message" case .ignoreDueToRequiresNoNotification: return "Ignored: Message requires no notification" + case .ignoreDueToMessageRequest: return "Ignored: Subsequent message in a message request" case .ignoreDueToDuplicateMessage: return "Ignored: Duplicate message (probably received it just before going to the background)" @@ -51,11 +52,10 @@ enum NotificationResolution: CustomStringConvertible { case .errorTimeout: return "Failed: Execution time expired" case .errorNotReadyForExtensions: return "Failed: App not ready for extensions" - case .errorNoContentLegacy: return "Failed: Legacy notification contained invalid payload" + case .errorLegacyPushNotification: return "Failed: Legacy push notifications are no longer supported" case .errorDatabaseInvalid: return "Failed: Database in invalid state" case .errorDatabaseMigrations(let error): return "Failed: Database migration error: \(error)" case .errorTransactionFailure: return "Failed: Unexpected database transaction rollback" - case .errorLegacyGroupKeysMissing: return "Failed: No legacy group decryption keys" case .errorCallFailure: return "Failed: Failed to handle call message" case .errorNoContent(let metadata): @@ -71,14 +71,15 @@ enum NotificationResolution: CustomStringConvertible { switch self { case .success, .successCall, .ignoreDueToMainAppRunning, .ignoreDueToNoContentFromApple, .ignoreDueToNonLegacyGroupLegacyNotification, .ignoreDueToOutdatedMessage, - .ignoreDueToRequiresNoNotification, .ignoreDueToDuplicateMessage, .ignoreDueToContentSize: + .ignoreDueToRequiresNoNotification, .ignoreDueToMessageRequest, .ignoreDueToDuplicateMessage, + .ignoreDueToContentSize: return .info - case .errorNotReadyForExtensions, .errorNoContentLegacy, .errorNoContent, .errorCallFailure: + case .errorNotReadyForExtensions, .errorLegacyPushNotification, .errorNoContent, .errorCallFailure: return .warn case .errorTimeout, .errorDatabaseInvalid, .errorDatabaseMigrations, .errorTransactionFailure, - .errorLegacyGroupKeysMissing, .errorProcessing, .errorMessageHandling, .errorOther: + .errorProcessing, .errorMessageHandling, .errorOther: return .error } } diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 880b224488..568d24918c 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -25,24 +25,15 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Called via the OS so create a default 'Dependencies' instance private var dependencies: Dependencies = Dependencies.createEmpty() private var startTime: CFTimeInterval = 0 - private var contentHandler: ((UNNotificationContent) -> Void)? - private var request: UNNotificationRequest? + private var cachedNotificationInfo: NotificationInfo = .invalid @ThreadSafe private var hasCompleted: Bool = false - - // stringlint:ignore_start - public static let isFromRemoteKey = "remote" - public static let threadIdKey = "Signal.AppNotificationsUserInfoKey.threadId" - public static let threadVariantRaw = "Signal.AppNotificationsUserInfoKey.threadVariantRaw" - public static let threadNotificationCounter = "Session.AppNotificationsUserInfoKey.threadNotificationCounter" - private static let callPreOfferLargeNotificationSupressionDuration: TimeInterval = 30 - // stringlint:ignore_stop - + // MARK: Did receive a remote push notification request override public func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { self.startTime = CACurrentMediaTime() - self.contentHandler = contentHandler - self.request = request + self.cachedNotificationInfo = self.cachedNotificationInfo.with(requestId: request.identifier) + self.cachedNotificationInfo = self.cachedNotificationInfo.with(contentHandler: contentHandler) /// Create a new `Dependencies` instance each time so we don't need to worry about state from previous /// notifications causing issues with new notifications @@ -53,11 +44,11 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Abort if the main app is running guard !dependencies[defaults: .appGroup, key: .isMainAppActive] else { - return self.completeSilenty(.ignoreDueToMainAppRunning, requestId: request.identifier) + return self.completeSilenty(self.cachedNotificationInfo, .ignoreDueToMainAppRunning) } - guard let notificationContent = request.content.mutableCopy() as? UNMutableNotificationContent else { - return self.completeSilenty(.ignoreDueToNoContentFromApple, requestId: request.identifier) + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + return self.completeSilenty(self.cachedNotificationInfo, .ignoreDueToNoContentFromApple) } Log.info(.cat, "didReceive called with requestId: \(request.identifier).") @@ -70,62 +61,489 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } } - /// Actually perform the setup - self.performSetup(requestId: request.identifier) { [weak self] in - self?.handleNotification(notificationContent, requestId: request.identifier) + /// Setup the extension and handle the notification + var notificationInfo: NotificationInfo = self.cachedNotificationInfo.with(content: content) + var processedNotification: ProcessedNotification = (self.cachedNotificationInfo, .invalid, "", nil, nil) + + do { + try performSetup(requestId: request.identifier) + notificationInfo = try extractNotificationInfo(notificationInfo) + processedNotification = try processNotification(notificationInfo) + try handleNotification(processedNotification) + } + catch { + handleError( + error, + info: notificationInfo, + processedNotification: processedNotification, + contentHandler: contentHandler + ) + } + } + + // MARK: - Setup + + private func performSetup(requestId: String) throws { + Log.info(.cat, "Performing setup for requestId: \(requestId).") + + dependencies.warmCache(cache: .appVersion) + + var migrationResult: Result = .failure(StorageError.startupFailed) + let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) + AppSetup.setupEnvironment( + requestId: requestId, + appSpecificBlock: { [dependencies] in + // stringlint:ignore_start + Log.setup(with: Logger( + primaryPrefix: "NotificationServiceExtension", + customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/NotificationExtension", + using: dependencies + )) + // stringlint:ignore_stop + + /// The `NotificationServiceExtension` needs custom behaviours for it's notification presenter so set it up here + dependencies.set(singleton: .notificationsManager, to: NSENotificationPresenter(using: dependencies)) + + // Setup LibSession + LibSession.setupLogger(using: dependencies) + + // Configure the different targets + SNUtilitiesKit.configure( + networkMaxFileSize: Network.maxFileSize, + using: dependencies + ) + SNMessagingKit.configure(using: dependencies) + }, + migrationsCompletion: { result in + migrationResult = result + semaphore.signal() + }, + using: dependencies + ) + + semaphore.wait() + + /// Ensure the migration was successful or throw the error + do { _ = try migrationResult.successOrThrow() } + catch { throw NotificationError.migration(error) } + + /// Ensure storage is actually valid + guard dependencies[singleton: .storage].isValid else { + throw NotificationError.databaseInvalid + } + + /// We should never receive a non-voip notification on an app that doesn't support app extensions since we have to inform the + /// service we wanted these, so in theory this path should never occur. However, the service does have our push token so it is + /// possible that could change in the future. If it does, do nothing and don't disturb the user. Messages will be processed when + /// they open the app. + guard dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { + throw NotificationError.notReadyForExtension + } + + /// If the app wasn't ready then mark it as ready now + if !dependencies[singleton: .appReadiness].isAppReady { + /// Note that this does much more than set a flag; it will also run all deferred blocks + dependencies[singleton: .appReadiness].setAppReady() } } - private func handleNotification(_ notificationContent: UNMutableNotificationContent, requestId: String) { + // MARK: - Notification Handling + + private func extractNotificationInfo(_ info: NotificationInfo) throws -> NotificationInfo { let (maybeData, metadata, result) = PushNotificationAPI.processNotification( - notificationContent: notificationContent, + notificationContent: info.content, using: dependencies ) - guard - (result == .success || result == .legacySuccess), - let data: Data = maybeData - else { - switch (result, metadata.namespace.isConfigNamespace) { - // If we got an explicit failure, or we got a success but no content then show - // the fallback notification - case (.success, false), (.legacySuccess, false), (.failure, false): - return self.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: nil, - threadDisplayName: nil, - resolution: .errorProcessing(result), - requestId: requestId - ) + switch (result, maybeData, metadata.namespace.isConfigNamespace) { + /// If we got an explicit failure, or we got a success but no content then show the fallback notification + case (.failure, _, false), (.success, .none, false): + throw NotificationError.processingErrorWithFallback(result, metadata) - case (.success, _), (.legacySuccess, _), (.failure, _): - return self.completeSilenty(.errorProcessing(result), requestId: requestId) + case (.success, .some(let data), _): + return NotificationInfo( + content: info.content, + requestId: info.requestId, + contentHandler: info.contentHandler, + metadata: metadata, + data: data + ) - // Just log if the notification was too long (a ~2k message should be able to fit so - // these will most commonly be call or config messages) - case (.successTooLong, _): - return self.completeSilenty(.ignoreDueToContentSize(metadata), requestId: requestId) + default: throw NotificationError.processingError(result, metadata) + } + } + + private func processNotification(_ info: NotificationInfo) throws -> ProcessedNotification { + let processedMessage: ProcessedMessage = try MessageReceiver.parse( + data: info.data, + origin: .swarm( + publicKey: info.metadata.accountId, + namespace: info.metadata.namespace, + serverHash: info.metadata.hash, + serverTimestampMs: info.metadata.createdTimestampMs, + serverExpirationTimestamp: ( + (TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + SnodeReceivedMessage.defaultExpirationMs) / 1000) + ) + ), + using: dependencies + ) + try MessageDeduplication.ensureMessageIsNotADuplicate(processedMessage, using: dependencies) + + let userSessionId: SessionId = dependencies[cache: .general].sessionId + var threadVariant: SessionThread.Variant? + var threadDisplayName: String? + + switch processedMessage { + case .invalid: throw MessageReceiverError.invalidMessage + case .config: + threadVariant = nil + threadDisplayName = nil - case (.failureNoContent, _): return self.completeSilenty(.errorNoContent(metadata), requestId: requestId) - case (.legacyFailure, _): return self.completeSilenty(.errorNoContentLegacy, requestId: requestId) - case (.legacyForceSilent, _): - return self.completeSilenty(.ignoreDueToNonLegacyGroupLegacyNotification, requestId: requestId) - } + case .standard(let threadId, let threadVariantVal, _, let messageInfo, _): + threadVariant = threadVariantVal + threadDisplayName = dependencies.mutate(cache: .libSession) { cache in + cache.conversationDisplayName( + threadId: threadId, + threadVariant: threadVariantVal, + contactProfile: nil, /// No database access in the NSE + visibleMessage: messageInfo.message as? VisibleMessage, + openGroupName: nil, /// Community PNs not currently supported + openGroupUrlInfo: nil /// Community PNs not currently supported + ) + } } - let isCallOngoing: Bool = ( - dependencies[defaults: .appGroup, key: .isCallOngoing] && - (dependencies[defaults: .appGroup, key: .lastCallPreOffer] != nil) + return ( + info, + processedMessage, + processedMessage.threadId, + threadVariant, + threadDisplayName + ) + } + + private func handleNotification(_ notification: ProcessedNotification) throws { + switch notification.processedMessage { + case .invalid: throw MessageReceiverError.invalidMessage + case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data, _): + try handleConfigMessage( + notification, + swarmPublicKey: swarmPublicKey, + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: serverTimestampMs, + data: data + ) + + case .standard(let threadId, let threadVariant, let proto, let messageInfo, _): + try handleStandardMessage( + notification, + threadId: threadId, + threadVariant: threadVariant, + proto: proto, + messageInfo: messageInfo + ) + } + } + + private func handleConfigMessage( + _ notification: ProcessedNotification, + swarmPublicKey: String, + namespace: SnodeAPI.Namespace, + serverHash: String, + serverTimestampMs: Int64, + data: Data + ) throws { + // TODO: [Database Relocation] Handle the config message case in a separate PR + return try dependencies.mutate(cache: .libSession) { cache in + try cache.mergeConfigMessages( + swarmPublicKey: swarmPublicKey, + messages: [ + ConfigMessageReceiveJob.Details.MessageInfo( + namespace: namespace, + serverHash: serverHash, + serverTimestampMs: serverTimestampMs, + data: data + ) + ], + afterMerge: { sessionId, variant, config, _ in + // TODO: [Database Relocation] Handle the config message case in a separate PR + } + ) + } + } + + private func handleStandardMessage( + _ notification: ProcessedNotification, + threadId: String, + threadVariant: SessionThread.Variant, + proto: SNProtoContent, + messageInfo: MessageReceiveJob.Details.MessageInfo + ) throws { + /// Throw if the message is outdated and shouldn't be processed (this is based on pretty flaky logic which checks if the config + /// has been updated since the message was sent - this should be reworked to be less edge-case prone in the future) + try MessageReceiver.throwIfMessageOutdated( + message: messageInfo.message, + threadId: threadId, + threadVariant: threadVariant, + openGroupUrlInfo: nil, /// Communities current don't support PNs + using: dependencies ) - let hasMicrophonePermission: Bool = { - switch Permissions.microphone { - case .undetermined: return dependencies[defaults: .appGroup, key: .lastSeenHasMicrophonePermission] - default: return (Permissions.microphone == .granted) + /// Define the `displayNameRetriever` so it can be reused + let displayNameRetriever: (String) -> String? = { [dependencies] sessionId in + (dependencies + .mutate(cache: .libSession) { cache in + cache.profile( + threadId: threadId, + threadVariant: threadVariant, + contactId: sessionId, + visibleMessage: (messageInfo.message as? VisibleMessage) + ) + }? + .displayName(for: threadVariant)) + .defaulting(to: Profile.truncated(id: sessionId, threadVariant: threadVariant)) + } + + /// Handle any specific logic needed for the notification extension based on the message type + switch messageInfo.message { + /// These have no notification-related behaviours so no need to do anything + case is TypingIndicator, is DataExtractionNotification, is ExpirationTimerUpdate, + is MessageRequestResponse: + break + + /// `ReadReceipt` and `UnsendRequest` messages only include basic information which can be used to lookup a + /// message so need database access in order to do anything (including removing existing notifications) so just ignore them + case is ReadReceipt, is UnsendRequest: break + + /// Control messages for `group` conversations + case is GroupUpdateInviteMessage, is GroupUpdateInfoChangeMessage, + is GroupUpdateMemberChangeMessage, is GroupUpdatePromoteMessage, + is GroupUpdateMemberLeftMessage, is GroupUpdateMemberLeftNotificationMessage, + is GroupUpdateInviteResponseMessage, is GroupUpdateDeleteMemberContentMessage: + // TODO: [Database Relocation] Handle group control messages in a separate PR + return handleNotificationViaDatabase(notification) + + /// Custom `group` conversation messages (eg. `kickedMessage`) + case is LibSessionMessage: + // TODO: [Database Relocation] Handle the LibSession message in a separate PR + return handleNotificationViaDatabase(notification) + + case var callMessage as CallMessage: + switch callMessage.kind { + case .preOffer: Log.info(.calls, "Received pre-offer message with uuid: \(callMessage.uuid).") + case .offer: Log.info(.calls, "Received offer message.") + case .answer: Log.info(.calls, "Received answer message.") + case .endCall: Log.info(.calls, "Received end call message.") + case .provisionalAnswer, .iceCandidates: break + } + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the + /// same call so we need to ensure the call message itself isn't a duplicate + try MessageDeduplication.ensureCallMessageIsNotADuplicate( + threadId: threadId, + callMessage: callMessage, + using: dependencies + ) + + // TODO: [Database Relocation] Need to store 'db[.areCallsEnabled]' in libSession + let areCallsEnabled: Bool = true // db[.areCallsEnabled] + let hasMicrophonePermission: Bool = { + switch Permissions.microphone { + case .undetermined: return dependencies[defaults: .appGroup, key: .lastSeenHasMicrophonePermission] + default: return (Permissions.microphone == .granted) + } + }() + let isCallOngoing: Bool = ( + dependencies[defaults: .appGroup, key: .isCallOngoing] && + (dependencies[defaults: .appGroup, key: .lastCallPreOffer] != nil) + ) + /// Handle the call as needed + switch ((areCallsEnabled && hasMicrophonePermission), isCallOngoing) { + case (false, _): + /// Update the `CallMessage.state` value so the correct notification logic can occur + callMessage.state = (areCallsEnabled ? .permissionDeniedMicrophone : .permissionDenied) + + case (true, true): + Log.info(.calls, "Sending end call message because there is an ongoing call.") + // TODO: [Database Relocation] Need to properly implement this logic (without the database requirement) + fatalError("NEED TO IMPLEMENT") +// try MessageReceiver.handleIncomingCallOfferInBusyState( +// db, +// message: callMessage, +// using: dependencies +// ) + + case (true, false): + guard + let sender: String = callMessage.sender, + let sentTimestampMs: UInt64 = callMessage.sentTimestampMs, + threadVariant == .contact, + !dependencies.mutate(cache: .libSession, { cache in + cache.isMessageRequest( + threadId: threadId, + threadVariant: threadVariant + ) + }) + else { throw MessageReceiverError.invalidMessage } + + return handleSuccessForIncomingCall( + notification, + threadVariant: threadVariant, + callMessage: callMessage, + sender: sender, + sentTimestampMs: sentTimestampMs, + displayNameRetriever: displayNameRetriever + ) + } + + case is VisibleMessage: break + default: throw MessageReceiverError.unknownMessage(proto) + } + + /// Try to show a notification for the message + /// + /// **Note:** No need to check blinded ids as Communities currently don't support PNs + let currentUserSessionIds: Set = [dependencies[cache: .general].sessionId.hexString] + try dependencies[singleton: .notificationsManager].notifyUser( + message: messageInfo.message, + threadId: threadId, + threadVariant: threadVariant, + interactionId: 0, + interactionVariant: Interaction.Variant( + message: messageInfo.message, + currentUserSessionIds: currentUserSessionIds + ), + attachmentDescriptionInfo: proto.dataMessage?.attachments.map { attachment in + Attachment.DescriptionInfo(id: "", proto: attachment) + }, + openGroupUrlInfo: nil, /// Communities currently don't support PNs + applicationState: .background, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever, + shouldShowForMessageRequest: { + !dependencies[singleton: .extensionHelper] + .hasAtLeastOneDedupeRecord(threadId: threadId) } - }() + ) + + /// Write the message to disk via the `extensionHelper` so the main app will have it immediately instead of having to wait + /// for a poll to return + // TODO: [Database Relocation] Add in this logic + /// Since we successfully handled the message we should now create the dedupe file for the message so we don't + /// show duplicate PNs + try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + + /// We need additional dedupe logic if the message is a `CallMessage` as multiple messages can related to the same call + if let callMessage: CallMessage = messageInfo.message as? CallMessage { + try dependencies[singleton: .extensionHelper].createDedupeRecord( + threadId: threadId, + uniqueIdentifier: callMessage.uuid + ) + } + } + + private func handleError( + _ error: Error, + info: NotificationInfo, + processedNotification: ProcessedNotification?, + contentHandler: ((UNNotificationContent) -> Void) + ) { + switch (error, processedNotification?.threadVariant, info.metadata.namespace.isConfigNamespace) { + case (NotificationError.migration(let error), _, _): + self.completeSilenty(info, .errorDatabaseMigrations(error)) + + case (NotificationError.databaseInvalid, _, _): + self.completeSilenty(info, .errorDatabaseInvalid) + + case (NotificationError.notReadyForExtension, _, _): + self.completeSilenty(info, .errorNotReadyForExtensions) + + case (NotificationError.processingErrorWithFallback(let result, let errorMetadata), _, _): + self.handleFailure( + info.with(metadata: errorMetadata), + threadVariant: nil, + threadDisplayName: nil, + resolution: .errorProcessing(result) + ) + + /// Just log if the notification was too long (a ~2k message should be able to fit so these will most commonly be call + /// or config messages) + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .successTooLong: + self.completeSilenty(info.with(metadata: errorMetadata), .ignoreDueToContentSize(errorMetadata)) + + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .failureNoContent: + self.completeSilenty(info.with(metadata: errorMetadata), .errorNoContent(errorMetadata)) + + case (NotificationError.processingError(let result, let errorMetadata), _, _) where result == .legacyFailure: + self.completeSilenty(info.with(metadata: errorMetadata), .errorLegacyPushNotification) + + case (NotificationError.processingError(let result, let errorMetadata), _, _): + self.completeSilenty(info.with(metadata: errorMetadata), .errorProcessing(result)) + + case (MessageReceiverError.noGroupKeyPair, _, _): + self.completeSilenty(info, .errorLegacyPushNotification) + + case (MessageReceiverError.outdatedMessage, _, _): + self.completeSilenty(info, .ignoreDueToOutdatedMessage) + + case (MessageReceiverError.ignorableMessage, _, _): + self.completeSilenty(info, .ignoreDueToRequiresNoNotification) + + case (MessageReceiverError.ignorableMessageRequestMessage, _, _): + self.completeSilenty(info, .ignoreDueToMessageRequest) + + case (MessageReceiverError.duplicateMessage, _, _): + self.completeSilenty(info, .ignoreDueToDuplicateMessage) + + /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't + /// want to show the fallback notification in this case) + case (MessageReceiverError.decryptionFailed, _, true): + self.completeSilenty(info, .errorMessageHandling(.decryptionFailed)) + + /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or + /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want + /// to show the fallback notification in these cases) + case (MessageReceiverError.decryptionFailed, .group, _): + guard + let threadId: String = processedNotification?.threadId, + dependencies.mutate(cache: .libSession, { cache in + cache.hasCredentials(groupSessionId: SessionId(.group, hex: threadId)) + }) + else { + self.completeSilenty(info, .errorMessageHandling(.decryptionFailed)) + return + } + + /// The thread exists and we should have been able to decrypt so show the fallback message + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorMessageHandling(.decryptionFailed) + ) + + case (let msgError as MessageReceiverError, _, _): + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorMessageHandling(msgError) + ) + + default: + self.handleFailure( + info, + threadVariant: processedNotification?.threadVariant, + threadDisplayName: processedNotification?.threadDisplayName, + resolution: .errorOther(error) + ) + } + } + + @available(*, deprecated, message: "This function will be removed as part of the Database Relocation work, but is being build in parts so will remain for now") + private func handleNotificationViaDatabase(_ notification: ProcessedNotification) { // HACK: It is important to use write synchronously here to avoid a race condition // where the completeSilenty() is called before the local notification request // is added to notification center @@ -135,130 +553,21 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension var threadDisplayName: String? do { - let processedMessage: ProcessedMessage = try Message.processRawReceivedMessageAsNotification( - db, - data: data, - metadata: metadata, - using: dependencies - ) - - switch processedMessage { - /// Custom handle config messages (as they don't get handled by the normal `MessageReceiver.handle` call - case .config(let swarmPublicKey, let namespace, let serverHash, let serverTimestampMs, let data): - try dependencies.mutate(cache: .libSession) { cache in - try cache.handleConfigMessages( - db, - swarmPublicKey: swarmPublicKey, - messages: [ - ConfigMessageReceiveJob.Details.MessageInfo( - namespace: namespace, - serverHash: serverHash, - serverTimestampMs: serverTimestampMs, - data: data - ) - ] - ) - } - - /// Due to the way the `CallMessage` works we need to custom handle it's behaviour within the notification - /// extension, for all other message types we want to just use the standard `MessageReceiver.handle` call - case .standard(let threadId, let threadVariant, _, let messageInfo) where messageInfo.message is CallMessage: - processedThreadId = threadId - processedThreadVariant = threadVariant - - guard let callMessage = messageInfo.message as? CallMessage else { - throw MessageReceiverError.ignorableMessage - } - - // Throw if the message is outdated and shouldn't be processed - try MessageReceiver.throwIfMessageOutdated( - db, - message: messageInfo.message, - threadId: threadId, - threadVariant: threadVariant, - using: dependencies - ) - - // FIXME: Do we need to call it here? It does nothing other than log what kind of message we received - try MessageReceiver.handleCallMessage( - db, - threadId: threadId, - threadVariant: threadVariant, - message: callMessage, - using: dependencies - ) - - guard case .preOffer = callMessage.kind else { - throw MessageReceiverError.ignorableMessage - } - - switch ((db[.areCallsEnabled] && hasMicrophonePermission), isCallOngoing) { - case (false, _): - if - let sender: String = callMessage.sender, - let interaction: Interaction = try MessageReceiver.insertCallInfoMessage( - db, - for: callMessage, - state: (db[.areCallsEnabled] ? .permissionDeniedMicrophone : .permissionDenied), - using: dependencies - ) - { - let thread: SessionThread = try SessionThread.upsert( - db, - id: sender, - variant: .contact, - values: SessionThread.TargetValues( - creationDateTimestamp: .useExistingOrSetTo( - (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) - ), - shouldBeVisible: .useExisting - ), - using: dependencies - ) - - // Notify the user if the call message wasn't already read - if !interaction.wasRead { - dependencies[singleton: .notificationsManager].notifyUser( - db, - forIncomingCall: interaction, - in: thread, - applicationState: .background - ) - } - } - - case (true, true): - try MessageReceiver.handleIncomingCallOfferInBusyState( - db, - message: callMessage, - using: dependencies - ) - - case (true, false): - try MessageReceiver.insertCallInfoMessage(db, for: callMessage, using: dependencies) - - // Perform any required post-handling logic - try MessageReceiver.postHandleMessage( - db, - threadId: threadId, - threadVariant: threadVariant, - message: messageInfo.message, - using: dependencies - ) + switch notification.processedMessage { + case .config, .invalid: return + case .standard(let threadId, let threadVariant, let proto, let messageInfo, _): + /// Only allow the cases with don't have updated handling through + switch messageInfo.message { + case is GroupUpdateInviteMessage, is GroupUpdateInfoChangeMessage, + is GroupUpdateMemberChangeMessage, is GroupUpdatePromoteMessage, + is GroupUpdateMemberLeftMessage, is GroupUpdateMemberLeftNotificationMessage, + is GroupUpdateInviteResponseMessage, is GroupUpdateDeleteMemberContentMessage: + break - return self?.handleSuccessForIncomingCall(db, for: callMessage, requestId: requestId) + case is LibSessionMessage: break + default: throw MessageReceiverError.invalidMessage } - // Perform any required post-handling logic - try MessageReceiver.postHandleMessage( - db, - threadId: threadId, - threadVariant: threadVariant, - message: messageInfo.message, - using: dependencies - ) - - case .standard(let threadId, let threadVariant, let proto, let messageInfo): processedThreadId = threadId processedThreadVariant = threadVariant threadDisplayName = SessionThread.displayName( @@ -297,9 +606,13 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension ) } + /// Since we successfully handled the message we should now create the dedupe file for the message so we don't + /// show duplicate PNs + try MessageDeduplication.createDedupeFile(notification.processedMessage, using: dependencies) + db.afterNextTransaction( - onCommit: { _ in self?.completeSilenty(.success(metadata), requestId: requestId) }, - onRollback: { _ in self?.completeSilenty(.errorTransactionFailure, requestId: requestId) } + onCommit: { _ in self?.completeSilenty(notification.info, .success(notification.info.metadata)) }, + onRollback: { _ in self?.completeSilenty(notification.info, .errorTransactionFailure) } ) } catch { @@ -309,69 +622,63 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // Dispatch to the next run loop to ensure we are out of the database write thread before // handling the result (and suspending the database) DispatchQueue.main.async { - switch (error, processedThreadVariant, metadata.namespace.isConfigNamespace) { + switch (error, notification.threadVariant, notification.info.metadata.namespace.isConfigNamespace) { case (MessageReceiverError.noGroupKeyPair, _, _): - self?.completeSilenty(.errorLegacyGroupKeysMissing, requestId: requestId) + self?.completeSilenty(notification.info, .errorLegacyPushNotification) case (MessageReceiverError.outdatedMessage, _, _): - self?.completeSilenty(.ignoreDueToOutdatedMessage, requestId: requestId) + self?.completeSilenty(notification.info, .ignoreDueToOutdatedMessage) case (MessageReceiverError.ignorableMessage, _, _): - self?.completeSilenty(.ignoreDueToRequiresNoNotification, requestId: requestId) + self?.completeSilenty(notification.info, .ignoreDueToRequiresNoNotification) - case (MessageReceiverError.duplicateMessage, _, _), - (MessageReceiverError.duplicateControlMessage, _, _), - (MessageReceiverError.duplicateMessageNewSnode, _, _): - self?.completeSilenty(.ignoreDueToDuplicateMessage, requestId: requestId) + case (MessageReceiverError.ignorableMessageRequestMessage, _, _): + self?.completeSilenty(notification.info, .ignoreDueToMessageRequest) + + case (MessageReceiverError.duplicateMessage, _, _): + self?.completeSilenty(notification.info, .ignoreDueToDuplicateMessage) /// If it was a `decryptionFailed` error, but it was for a config namespace then just fail silently (don't /// want to show the fallback notification in this case) case (MessageReceiverError.decryptionFailed, _, true): - self?.completeSilenty(.errorMessageHandling(.decryptionFailed), requestId: requestId) + self?.completeSilenty(notification.info, .errorMessageHandling(.decryptionFailed)) /// If it was a `decryptionFailed` error for a group conversation and the group doesn't exist or /// doesn't have auth info (ie. group destroyed or member kicked), then just fail silently (don't want /// to show the fallback notification in these cases) case (MessageReceiverError.decryptionFailed, .group, _): guard - let threadId: String = processedThreadId, - let group: ClosedGroup = try? ClosedGroup.fetchOne(db, id: threadId), ( + let group: ClosedGroup = try? ClosedGroup.fetchOne(db, id: notification.threadId), ( group.groupIdentityPrivateKey != nil || group.authData != nil ) else { - self?.completeSilenty(.errorMessageHandling(.decryptionFailed), requestId: requestId) + self?.completeSilenty(notification.info, .errorMessageHandling(.decryptionFailed)) return } /// The thread exists and we should have been able to decrypt so show the fallback message self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorMessageHandling(.decryptionFailed), - requestId: requestId + notification.info, + threadVariant: notification.threadVariant, + threadDisplayName: notification.threadDisplayName, + resolution: .errorMessageHandling(.decryptionFailed) ) case (let msgError as MessageReceiverError, _, _): self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorMessageHandling(msgError), - requestId: requestId + notification.info, + threadVariant: notification.threadVariant, + threadDisplayName: notification.threadDisplayName, + resolution: .errorMessageHandling(msgError) ) default: self?.handleFailure( - for: notificationContent, - metadata: metadata, - threadVariant: processedThreadVariant, - threadDisplayName: threadDisplayName, - resolution: .errorOther(error), - requestId: requestId + notification.info, + threadVariant: notification.threadVariant, + threadDisplayName: notification.threadDisplayName, + resolution: .errorOther(error) ) } } @@ -385,87 +692,21 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } } } - - // MARK: Setup - - private func performSetup(requestId: String, completion: @escaping () -> Void) { - Log.info(.cat, "Performing setup for requestId: \(requestId).") - - dependencies.warmCache(cache: .appVersion) - - AppSetup.setupEnvironment( - requestId: requestId, - appSpecificBlock: { [dependencies] in - // stringlint:ignore_start - Log.setup(with: Logger( - primaryPrefix: "NotificationServiceExtension", - customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/NotificationExtension", - using: dependencies - )) - // stringlint:ignore_stop - - /// The `NotificationServiceExtension` needs custom behaviours for it's notification presenter so set it up here - dependencies.set(singleton: .notificationsManager, to: NSENotificationPresenter(using: dependencies)) - - // Setup LibSession - LibSession.setupLogger(using: dependencies) - - // Configure the different targets - SNUtilitiesKit.configure( - networkMaxFileSize: Network.maxFileSize, - using: dependencies - ) - SNMessagingKit.configure(using: dependencies) - }, - migrationsCompletion: { [weak self, dependencies] result in - switch result { - case .failure(let error): self?.completeSilenty(.errorDatabaseMigrations(error), requestId: requestId) - case .success: - DispatchQueue.main.async { - // Ensure storage is actually valid - guard dependencies[singleton: .storage].isValid else { - self?.completeSilenty(.errorDatabaseInvalid, requestId: requestId) - return - } - - // We should never receive a non-voip notification on an app that doesn't support - // app extensions since we have to inform the service we wanted these, so in theory - // this path should never occur. However, the service does have our push token - // so it is possible that could change in the future. If it does, do nothing - // and don't disturb the user. Messages will be processed when they open the app. - guard dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { - self?.completeSilenty(.errorNotReadyForExtensions, requestId: requestId) - return - } - - // If the app wasn't ready then mark it as ready now - if !dependencies[singleton: .appReadiness].isAppReady { - // Note that this does much more than set a flag; it will also run all deferred blocks. - dependencies[singleton: .appReadiness].setAppReady() - } - - completion() - } - } - }, - using: dependencies - ) - } // MARK: Handle completion override public func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - completeSilenty(.errorTimeout, requestId: (request?.identifier ?? "N/A")) // stringlint:ignore + /// Called just before the extension will be terminated by the system + completeSilenty(cachedNotificationInfo, .errorTimeout) } - private func completeSilenty(_ resolution: NotificationResolution, requestId: String) { + private func completeSilenty(_ info: NotificationInfo, _ resolution: NotificationResolution) { // This can be called from within database threads so to prevent blocking and weird // behaviours make sure to send it to the main thread instead + // TODO: [Database Relocation] Should be able to remove this guard Thread.isMainThread else { return DispatchQueue.main.async { [weak self] in - self?.completeSilenty(resolution, requestId: requestId) + self?.completeSilenty(info, resolution) } } @@ -477,140 +718,145 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension switch resolution { case .ignoreDueToMainAppRunning: break default: - /// Update the app badge in case the unread count changed - if - let unreadCount: Int = dependencies[singleton: .storage].read({ [dependencies] db in - try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) - }) - { - silentContent.badge = NSNumber(value: unreadCount) - } + // TODO: [Database Relocation] Need to get the unread count + break +// /// Update the app badge in case the unread count changed +// if +// let unreadCount: Int = dependencies[singleton: .storage].read({ [dependencies] db in +// try Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) +// }) +// { +// silentContent.badge = NSNumber(value: unreadCount) +// } - dependencies[singleton: .storage].suspendDatabaseAccess() +// dependencies[singleton: .storage].suspendDatabaseAccess() } let duration: CFTimeInterval = (CACurrentMediaTime() - startTime) - Log.custom(resolution.logLevel, [.cat], "\(resolution) after \(.seconds(duration), unit: .ms), requestId: \(requestId).") + Log.custom(resolution.logLevel, [.cat], "\(resolution) after \(.seconds(duration), unit: .ms), requestId: \(info.requestId).") Log.flush() Log.reset() - self.contentHandler!(silentContent) + info.contentHandler(silentContent) } private func handleSuccessForIncomingCall( - _ db: Database, - for callMessage: CallMessage, - requestId: String + _ notification: ProcessedNotification, + threadVariant: SessionThread.Variant, + callMessage: CallMessage, + sender: String, + sentTimestampMs: UInt64, + displayNameRetriever: @escaping (String) -> String? ) { - if Preferences.isCallKitSupported { - guard let caller: String = callMessage.sender, let timestamp = callMessage.sentTimestampMs else { return } - let contactName: String = Profile.displayName( - db, - id: caller, - threadVariant: .contact, - using: dependencies - ) - - let reportCall: () -> () = { [weak self, dependencies] in - // stringlint:ignore_start - let payload: [String: Any] = [ - "uuid": callMessage.uuid, - "caller": caller, - "timestamp": timestamp, - "contactName": contactName - ] - // stringlint:ignore_stop - - CXProvider.reportNewIncomingVoIPPushPayload(payload) { error in - if let error = error { - Log.error(.cat, "Failed to notify main app of call message: \(error).") - dependencies[singleton: .storage].read { db in - self?.handleFailureForVoIP(db, for: callMessage, requestId: requestId) - } - } - else { - dependencies[defaults: .appGroup, key: .lastCallPreOffer] = Date() - self?.completeSilenty(.successCall, requestId: requestId) - } - } - } - - db.afterNextTransaction( - onCommit: { _ in reportCall() }, - onRollback: { _ in reportCall() } + guard Preferences.isCallKitSupported else { + return handleFailureForVoIP( + notification, + threadVariant: threadVariant, + callMessage: callMessage, + displayNameRetriever: displayNameRetriever ) } - else { - self.handleFailureForVoIP(db, for: callMessage, requestId: requestId) + + // stringlint:ignore_start + let payload: [String: Any] = [ + "uuid": callMessage.uuid, + "caller": sender, + "timestamp": sentTimestampMs, + "contactName": displayNameRetriever(sender) + .defaulting(to: Profile.truncated(id: sender, threadVariant: threadVariant)) + ] + // stringlint:ignore_stop + + CXProvider.reportNewIncomingVoIPPushPayload(payload) { [weak self, dependencies] error in + if let error = error { + Log.error(.cat, "Failed to notify main app of call message: \(error).") + self?.handleFailureForVoIP( + notification, + threadVariant: threadVariant, + callMessage: callMessage, + displayNameRetriever: displayNameRetriever + ) + } + else { + dependencies[defaults: .appGroup, key: .lastCallPreOffer] = Date() + self?.completeSilenty(notification.info, .successCall) + } } } - private func handleFailureForVoIP(_ db: Database, for callMessage: CallMessage, requestId: String) { + private func handleFailureForVoIP( + _ notification: ProcessedNotification, + threadVariant: SessionThread.Variant, + callMessage: CallMessage, + displayNameRetriever: (String) -> String? + ) { let notificationContent = UNMutableNotificationContent() - notificationContent.userInfo = [ NotificationServiceExtension.isFromRemoteKey : true ] + notificationContent.userInfo = [ NotificationUserInfoKey.isFromRemote: true ] notificationContent.title = Constants.app_name + notificationContent.body = callMessage.sender + .map { sender in displayNameRetriever(sender) } + .map { senderDisplayName in + "callsIncoming" + .put(key: "name", value: senderDisplayName) + .localized() + } + .defaulting(to: "callsIncomingUnknown".localized()) - /// Update the app badge in case the unread count changed - if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { - notificationContent.badge = NSNumber(value: unreadCount) - } - - if let sender: String = callMessage.sender { - let senderDisplayName: String = Profile.displayName(db, id: sender, threadVariant: .contact, using: dependencies) - notificationContent.body = "callsIncoming" - .put(key: "name", value: senderDisplayName) - .localized() - } - else { - notificationContent.body = "callsIncomingUnknown".localized() - } + // TODO: [Database Relocation] Need to get the unread count +// /// Update the app badge in case the unread count changed +// if let unreadCount: Int = try? Interaction.fetchAppBadgeUnreadCount(db, using: dependencies) { +// notificationContent.badge = NSNumber(value: unreadCount) +// } - let identifier = self.request?.identifier ?? UUID().uuidString - let request = UNNotificationRequest(identifier: identifier, content: notificationContent, trigger: nil) + let request = UNNotificationRequest( + identifier: notification.info.requestId, + content: notificationContent, + trigger: nil + ) let semaphore = DispatchSemaphore(value: 0) UNUserNotificationCenter.current().add(request) { error in if let error = error { - Log.error(.cat, "Failed to add notification request for requestId: \(requestId) due to error: \(error).") + Log.error(.cat, "Failed to add notification request for requestId: \(notification.info.requestId) due to error: \(error).") } semaphore.signal() } semaphore.wait() - Log.info(.cat, "Add remote notification request for requestId: \(requestId).") + Log.info(.cat, "Add remote notification request for requestId: \(notification.info.requestId).") - db.afterNextTransaction( - onCommit: { [weak self] _ in self?.completeSilenty(.errorCallFailure, requestId: requestId) }, - onRollback: { [weak self] _ in self?.completeSilenty(.errorTransactionFailure, requestId: requestId) } - ) + completeSilenty(notification.info, .errorCallFailure) } private func handleFailure( - for content: UNMutableNotificationContent, - metadata: PushNotificationAPI.NotificationMetadata, + _ info: NotificationInfo, threadVariant: SessionThread.Variant?, threadDisplayName: String?, - resolution: NotificationResolution, - requestId: String + resolution: NotificationResolution ) { // This can be called from within database threads so to prevent blocking and weird // behaviours make sure to send it to the main thread instead + // TODO: [Database Relocation] Should be able to remove this guard Thread.isMainThread else { return DispatchQueue.main.async { [weak self] in self?.handleFailure( - for: content, - metadata: metadata, + info, threadVariant: threadVariant, threadDisplayName: threadDisplayName, - resolution: resolution, - requestId: requestId + resolution: resolution ) } } let duration: CFTimeInterval = (CACurrentMediaTime() - startTime) - let previewType: Preferences.NotificationPreviewType = dependencies[singleton: .storage, key: .preferencesNotificationPreviewType] - .defaulting(to: .nameAndPreview) - Log.error(.cat, "\(resolution) after \(.seconds(duration), unit: .ms), showing generic failure message for message from namespace: \(metadata.namespace), requestId: \(requestId).") + let targetThreadVariant: SessionThread.Variant = (threadVariant ?? .contact) /// Fallback to `contact` + let notificationSettings: Preferences.NotificationSettings = dependencies.mutate(cache: .libSession) { cache in + cache.notificationSettings( + threadId: info.metadata.accountId, + threadVariant: targetThreadVariant, + openGroupUrlInfo: nil /// Communities current don't support PNs + ) + } + Log.error(.cat, "\(resolution) after \(.seconds(duration), unit: .ms), showing generic failure message for message from namespace: \(info.metadata.namespace), requestId: \(info.requestId).") /// Now we are done with the database, we should suspend it if !dependencies[defaults: .appGroup, key: .isMainAppActive] { @@ -621,26 +867,81 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension Log.flush() Log.reset() - content.title = Constants.app_name - content.userInfo = [ NotificationServiceExtension.isFromRemoteKey: true ] + info.content.title = Constants.app_name + info.content.userInfo = [ NotificationUserInfoKey.isFromRemote: true ] /// If it's a notification for a group conversation, the notification preferences are right and we have a name for the group /// then we should include it in the notification content - switch (threadVariant, previewType, threadDisplayName) { + switch (targetThreadVariant, notificationSettings.previewType, threadDisplayName) { case (.group, .nameAndPreview, .some(let name)), (.group, .nameNoPreview, .some(let name)), (.legacyGroup, .nameAndPreview, .some(let name)), (.legacyGroup, .nameNoPreview, .some(let name)): - content.body = "messageNewYouveGotGroup" + info.content.body = "messageNewYouveGotGroup" .putNumber(1) .put(key: "group_name", value: name) .localized() default: - content.body = "messageNewYouveGot" + info.content.body = "messageNewYouveGot" .putNumber(1) .localized() } - contentHandler!(content) + info.contentHandler(info.content) hasCompleted = true } } + +// MARK: - Convenience + +private extension NotificationServiceExtension { + struct NotificationInfo { + static let invalid: NotificationInfo = NotificationInfo( + content: UNMutableNotificationContent(), + requestId: "N/A", // stringlint:ignore + contentHandler: { _ in }, + metadata: .invalid, + data: Data() + ) + + let content: UNMutableNotificationContent + let requestId: String + let contentHandler: ((UNNotificationContent) -> Void) + let metadata: PushNotificationAPI.NotificationMetadata + let data: Data + + func with( + content: UNMutableNotificationContent? = nil, + requestId: String? = nil, + contentHandler: ((UNNotificationContent) -> Void)? = nil, + metadata: PushNotificationAPI.NotificationMetadata? = nil + ) -> NotificationInfo { + return NotificationInfo( + content: (content ?? self.content), + requestId: (requestId ?? self.requestId), + contentHandler: (contentHandler ?? self.contentHandler), + metadata: (metadata ?? self.metadata), + data: data + ) + } + } + + typealias ProcessedNotification = ( + info: NotificationInfo, + processedMessage: ProcessedMessage, + threadId: String, + threadVariant: SessionThread.Variant?, + threadDisplayName: String? + ) + + enum NotificationError: Error { + case notReadyForExtension + case processingErrorWithFallback(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) + case processingError(PushNotificationAPI.ProcessResult, PushNotificationAPI.NotificationMetadata) + + @available(*, deprecated, message: "Should be removed as part of the database relocation work once the notification extension no longer needs the database") + case migration(Error) + + @available(*, deprecated, message: "Should be removed as part of the database relocation work once the notification extension no longer needs the database") + case databaseInvalid + } +} diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 94022afe67..30692d6757 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -41,27 +41,22 @@ public class ThreadPickerViewModel { .map { threadViewModel in let wasKickedFromGroup: Bool = ( threadViewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.wasKickedFromGroup(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + } ) let groupIsDestroyed: Bool = ( threadViewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) + dependencies.mutate(cache: .libSession) { cache in + cache.groupIsDestroyed(groupSessionId: SessionId(.group, hex: threadViewModel.threadId)) + } ) return threadViewModel.populatingPostQueryData( - db, - currentUserBlinded15SessionIdForThisThread: nil, - currentUserBlinded25SessionIdForThisThread: nil, + currentUserSessionIds: [userSessionId.hexString], wasKickedFromGroup: wasKickedFromGroup, groupIsDestroyed: groupIsDestroyed, - threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies), - using: dependencies + threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies) ) } } diff --git a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift index 6635914e29..eb11bc57e0 100644 --- a/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift +++ b/SessionSnodeKit/Database/Migrations/_002_SetupStandardJobs.swift @@ -13,6 +13,13 @@ enum _002_SetupStandardJobs: Migration { static let createdTables: [(TableRecord & FetchableRecord).Type] = [] static func migrate(_ db: Database, using dependencies: Dependencies) throws { + /// Only insert jobs if the `jobs` table exists or we aren't running tests (when running tests this allows us to skip running the + /// SNUtilitiesKit migrations) + guard + !SNUtilitiesKit.isRunningTests || + ((try? db.tableExists("job")) == true) + else { return Storage.update(progress: 1, for: self, in: target, using: dependencies) } + // Note: We also want this job to run both onLaunch and onActive as we want it to block // 'onLaunch' and 'onActive' doesn't support blocking jobs try db.execute(sql: """ diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 68b6c394f6..804677d9b1 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -125,4 +125,12 @@ public extension SnodeReceivedMessageInfo { ) } } + + func storeUpdatedLastHash(_ db: Database) -> Bool { + do { + _ = try self.inserted(db) + return true + } + catch { return false } + } } diff --git a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift index f6213ebfcb..58ad80dd3d 100644 --- a/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift +++ b/SessionSnodeKit/Models/SnodeAuthenticatedRequestBody.swift @@ -41,9 +41,9 @@ public class SnodeAuthenticatedRequestBody: Encodable { try container.encodeIfPresent(timestampMs, forKey: .timestampMs) switch authMethod.info { - case .standard(let sessionId, let ed25519KeyPair): + case .standard(let sessionId, let ed25519PublicKey): try container.encode(sessionId.hexString, forKey: .pubkey) - try container.encode(ed25519KeyPair.publicKey.toHexString(), forKey: .ed25519PublicKey) + try container.encode(ed25519PublicKey.toHexString(), forKey: .ed25519PublicKey) case .groupAdmin(let sessionId, _): try container.encode(sessionId.hexString, forKey: .pubkey) diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index c950a74a6b..d6b8da9c2d 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -8,7 +8,10 @@ import SessionUtilitiesKit public struct SnodeReceivedMessage: CustomDebugStringConvertible { /// Service nodes cache messages for 14 days so default the expiration for message hashes to '15' days /// so we don't end up indefinitely storing records which will never be used - public static let defaultExpirationSeconds: Int64 = ((15 * 24 * 60 * 60) * 1000) + public static let defaultExpirationMs: Int64 = ((15 * 24 * 60 * 60) * 1000) + + /// The storage server allows the timestamp within requests to be off by `60s` before erroring + public static let serverClockToleranceMs: Int64 = ((1 * 60) * 1000) public let info: SnodeReceivedMessageInfo public let namespace: SnodeAPI.Namespace @@ -31,7 +34,7 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { swarmPublicKey: publicKey, namespace: namespace, hash: rawMessage.hash, - expirationDateMs: (rawMessage.expiration ?? SnodeReceivedMessage.defaultExpirationSeconds) + expirationDateMs: (rawMessage.expiration ?? SnodeReceivedMessage.defaultExpirationMs) ) self.namespace = namespace self.timestampMs = rawMessage.timestampMs diff --git a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift index 94253ab78f..b14994f363 100644 --- a/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift +++ b/SessionTests/Conversations/Settings/ThreadSettingsViewModelSpec.swift @@ -64,30 +64,7 @@ class ThreadSettingsViewModelSpec: QuickSpec { } ) @TestState(cache: .libSession, in: dependencies) var mockLibSessionCache: MockLibSessionCache! = MockLibSessionCache( - initialSetup: { cache in - cache - .when { try $0.performAndPushChange(.any, for: .any, sessionId: .any, change: { _ in }) } - .thenReturn(()) - cache - .when { $0.pinnedPriority(.any, threadId: .any, threadVariant: .any) } - .thenReturn(LibSession.defaultNewThreadPriority) - cache.when { $0.disappearingMessagesConfig(threadId: .any, threadVariant: .any) } - .thenReturn(nil) - cache - .when { $0.isAdmin(groupSessionId: .any) } - .thenReturn(false) - cache - .when { try $0.withCustomBehaviour(.any, for: .any, variant: .any, change: { }) } - .then { args, untrackedArgs in - let callback: (() throws -> Void)? = (untrackedArgs[test: 0] as? () throws -> Void) - try? callback?() - } - .thenReturn(()) - cache.when { $0.isEmpty }.thenReturn(false) - cache - .when { try $0.pendingChanges(.any, swarmPublicKey: .any) } - .thenReturn(LibSession.PendingChanges()) - } + initialSetup: { $0.defaultInitialSetup() } ) @TestState(singleton: .crypto, in: dependencies) var mockCrypto: MockCrypto! = MockCrypto( initialSetup: { crypto in diff --git a/Session/Utilities/MentionUtilities.swift b/SessionUIKit/Utilities/MentionUtilities.swift similarity index 81% rename from Session/Utilities/MentionUtilities.swift rename to SessionUIKit/Utilities/MentionUtilities.swift index 193d6a8c59..9dd5ce41cc 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/SessionUIKit/Utilities/MentionUtilities.swift @@ -1,10 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation -import GRDB -import SessionUIKit -import SessionMessagingKit -import SessionUtilitiesKit +import UIKit public enum MentionUtilities { public enum MentionLocation { @@ -18,25 +15,19 @@ public enum MentionUtilities { public static func highlightMentionsNoAttributes( in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionId: String, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, - using dependencies: Dependencies + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String? ) -> String { /// **Note:** We are returning the string here so the 'textColor' and 'primaryColor' values are irrelevant return highlightMentions( in: string, - threadVariant: threadVariant, - currentUserSessionId: currentUserSessionId, - currentUserBlinded15SessionId: currentUserBlinded15SessionId, - currentUserBlinded25SessionId: currentUserBlinded25SessionId, + currentUserSessionIds: currentUserSessionIds, location: .styleFree, textColor: .black, theme: .classicDark, primaryColor: Theme.PrimaryColor.green, attributes: [:], - using: dependencies + displayNameRetriever: displayNameRetriever ) .string .deformatted() @@ -44,16 +35,13 @@ public enum MentionUtilities { public static func highlightMentions( in string: String, - threadVariant: SessionThread.Variant, - currentUserSessionId: String?, - currentUserBlinded15SessionId: String?, - currentUserBlinded25SessionId: String?, + currentUserSessionIds: Set, location: MentionLocation, textColor: UIColor, theme: Theme, primaryColor: Theme.PrimaryColor, attributes: [NSAttributedString.Key: Any], - using dependencies: Dependencies + displayNameRetriever: (String) -> String? ) -> NSAttributedString { guard let regex: NSRegularExpression = try? NSRegularExpression(pattern: "@[0-9a-fA-F]{66}", options: []) @@ -64,13 +52,6 @@ public enum MentionUtilities { var string = string var lastMatchEnd: Int = 0 var mentions: [(range: NSRange, isCurrentUser: Bool)] = [] - let currentUserSessionIds: Set = [ - currentUserSessionId, - currentUserBlinded15SessionId, - currentUserBlinded25SessionId - ] - .compactMap { $0 } - .asSet() while let match: NSTextCheckingResult = regex.firstMatch( in: string, @@ -84,8 +65,7 @@ public enum MentionUtilities { guard let targetString: String = { guard !isCurrentUser else { return "you".localized() } - // FIXME: This does a database query and is happening when populating UI - should try to refactor it somehow (ideally resolve a set of mentioned profiles as part of the database query) - guard let displayName: String = Profile.displayNameNoFallback(id: sessionId, threadVariant: threadVariant, using: dependencies) else { + guard let displayName: String = displayNameRetriever(sessionId) else { lastMatchEnd = (match.range.location + match.range.length) return nil } @@ -151,3 +131,16 @@ public enum MentionUtilities { return result } } + +public extension String { + func replacingMentions( + currentUserSessionIds: Set, + displayNameRetriever: (String) -> String? + ) -> String { + return MentionUtilities.highlightMentionsNoAttributes( + in: self, + currentUserSessionIds: currentUserSessionIds, + displayNameRetriever: displayNameRetriever + ) + } +} diff --git a/SessionUtilitiesKit/Crypto/CryptoError.swift b/SessionUtilitiesKit/Crypto/CryptoError.swift index 0194aa61f1..818a1b01b7 100644 --- a/SessionUtilitiesKit/Crypto/CryptoError.swift +++ b/SessionUtilitiesKit/Crypto/CryptoError.swift @@ -11,4 +11,5 @@ public enum CryptoError: Error { case encryptionFailed case decryptionFailed case failedToGenerateOutput + case missingUserSecretKey } diff --git a/SessionUtilitiesKit/Database/Models/Identity.swift b/SessionUtilitiesKit/Database/Models/Identity.swift index 732dbb156d..57bc300b87 100644 --- a/SessionUtilitiesKit/Database/Models/Identity.swift +++ b/SessionUtilitiesKit/Database/Models/Identity.swift @@ -77,17 +77,6 @@ public extension Identity { try Identity(variant: .x25519PublicKey, data: Data(x25519KeyPair.publicKey)).upsert(db) } - static func userExists( - _ db: Database? = nil, - using dependencies: Dependencies - ) -> Bool { - guard let db: Database = db else { - return (dependencies[singleton: .storage].read { db in Identity.userExists(db, using: dependencies) } ?? false) - } - - return (fetchUserEd25519KeyPair(db) != nil) - } - static func fetchUserKeyPair(_ db: Database) -> KeyPair? { guard let publicKey: Data = try? Identity.fetchOne(db, id: .x25519PublicKey)?.data, diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index f12284561a..9e47e9cd64 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -164,7 +164,13 @@ open class Storage { /// **Note:** If we fail to get/generate the keySpec then don't bother continuing to setup the Database as it'll just be invalid, /// in this case the App/Extensions will have logic that checks the `isValid` flag of the database do { - var tmpKeySpec: Data = try getOrGenerateDatabaseKeySpec() + var tmpKeySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .dbCipherKeySpec, + length: Storage.SQLCipherKeySpecLength, + cat: .storage, + legacyKey: "GRDBDatabaseCipherKeySpec", + legacyService: "TSKeyChainService" + ) tmpKeySpec.resetBytes(in: 0.. Data { - do { - var keySpec: Data = try getDatabaseCipherKeySpec() - defer { keySpec.resetBytes(in: 0.. String { - var keySpec: Data = try getOrGenerateDatabaseKeySpec() + var keySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey( + forKey: .dbCipherKeySpec, + length: Storage.SQLCipherKeySpecLength, + cat: .storage, + legacyKey: "GRDBDatabaseCipherKeySpec", + legacyService: "TSKeyChainService" + ) defer { keySpec.resetBytes(in: 0.. = Dependencies.create( identifier: "general", - createInstance: { _ in General.Cache() }, + createInstance: { dependencies in General.Cache(using: dependencies) }, mutableInstance: { $0 }, immutableInstance: { $0 } ) @@ -18,15 +18,41 @@ public extension Cache { public enum General { public class Cache: GeneralCacheType { + private let dependencies: Dependencies public var sessionId: SessionId = SessionId.invalid + public var ed25519SecretKey: [UInt8] = [] public var recentReactionTimestamps: [Int64] = [] public let placeholderCache: LRUCache = LRUCache(maxCacheSize: 50) public var contextualActionLookupMap: [Int: [String: [Int: Any]]] = [:] + public var userExists: Bool { !ed25519SecretKey.isEmpty } + public var ed25519Seed: [UInt8] { + guard ed25519SecretKey.count >= 32 else { return [] } + + return Array(ed25519SecretKey.prefix(upTo: 32)) + } + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + // MARK: - Functions - public func setCachedSessionId(sessionId: SessionId) { - self.sessionId = sessionId + public func setSecretKey(ed25519SecretKey: [UInt8]) { + guard + ed25519SecretKey.count >= 32, + let ed25519KeyPair: KeyPair = dependencies[singleton: .crypto].generate( + .ed25519KeyPair(seed: Array(ed25519SecretKey.prefix(upTo: 32))) + ), + let x25519PublicKey: [UInt8] = dependencies[singleton: .crypto].generate( + .x25519(ed25519Pubkey: ed25519KeyPair.publicKey) + ) + else { return } + + self.sessionId = SessionId(.standard, publicKey: x25519PublicKey) + self.ed25519SecretKey = ed25519SecretKey } } } @@ -35,17 +61,23 @@ public enum General { /// This is a read-only version of the Cache designed to avoid unintentionally mutating the instance in a non-thread-safe way public protocol ImmutableGeneralCacheType: ImmutableCacheType { + var userExists: Bool { get } var sessionId: SessionId { get } + var ed25519Seed: [UInt8] { get } + var ed25519SecretKey: [UInt8] { get } var recentReactionTimestamps: [Int64] { get } var placeholderCache: LRUCache { get } var contextualActionLookupMap: [Int: [String: [Int: Any]]] { get } } public protocol GeneralCacheType: ImmutableGeneralCacheType, MutableCacheType { + var userExists: Bool { get } var sessionId: SessionId { get } + var ed25519Seed: [UInt8] { get } + var ed25519SecretKey: [UInt8] { get } var recentReactionTimestamps: [Int64] { get set } var placeholderCache: LRUCache { get } var contextualActionLookupMap: [Int: [String: [Int: Any]]] { get set } - func setCachedSessionId(sessionId: SessionId) + func setSecretKey(ed25519SecretKey: [UInt8]) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 6ffd4ea45d..446eb0e342 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -65,17 +65,6 @@ public extension String { return ranges } - static func filterNotificationText(_ text: String?) -> String? { - guard let text = text?.filteredForDisplay else { return nil } - - // 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. - return text.replacingOccurrences(of: "%", with: "%%") - } - func appending(_ other: String?) -> String { guard let value: String = other else { return self } @@ -210,6 +199,15 @@ public extension String { return self.trimmingCharacters(in: .whitespacesAndNewlines) } + /// 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/usernotifications/unnotificationcontent/body for + /// more details. + var filteredForNotification: String { + self.replacingOccurrences(of: "%", with: "%%") + } + private var hasExcessiveDiacriticals: Bool { for char in self.enumerated() { let scalarCount = String(char.element).unicodeScalars.count diff --git a/SessionUtilitiesKit/Types/FileManager.swift b/SessionUtilitiesKit/Types/FileManager.swift index e77f92853d..49cf608180 100644 --- a/SessionUtilitiesKit/Types/FileManager.swift +++ b/SessionUtilitiesKit/Types/FileManager.swift @@ -47,12 +47,16 @@ public protocol FileManagerType { func contents(atPath: String) -> Data? func contentsOfDirectory(at url: URL) throws -> [URL] func contentsOfDirectory(atPath path: String) throws -> [String] + func isDirectoryEmpty(at url: URL) -> Bool + func isDirectoryEmpty(atPath path: String) -> Bool func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool func createDirectory(at url: URL, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws func createDirectory(atPath: String, withIntermediateDirectories: Bool, attributes: [FileAttributeKey: Any]?) throws func copyItem(atPath: String, toPath: String) throws func copyItem(at fromUrl: URL, to toUrl: URL) throws + func moveItem(atPath: String, toPath: String) throws + func moveItem(at fromUrl: URL, to toUrl: URL) throws func removeItem(atPath: String) throws func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] @@ -288,6 +292,22 @@ public class SessionFileManager: FileManagerType { public func contentsOfDirectory(atPath path: String) throws -> [String] { return try fileManager.contentsOfDirectory(atPath: path) } + + public func isDirectoryEmpty(at url: URL) -> Bool { + guard + let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return false } + + /// If `nextObject()` returns `nil` immediately, there were no items + return enumerator.nextObject() == nil + } + + public func isDirectoryEmpty(atPath path: String) -> Bool { + return isDirectoryEmpty(at: URL(fileURLWithPath: path)) + } public func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey: Any]?) -> Bool { return fileManager.createFile(atPath: atPath, contents: contents, attributes: attributes) @@ -317,6 +337,14 @@ public class SessionFileManager: FileManagerType { return try fileManager.copyItem(at: fromUrl, to: toUrl) } + public func moveItem(atPath: String, toPath: String) throws { + try fileManager.moveItem(atPath: atPath, toPath: toPath) + } + + public func moveItem(at fromUrl: URL, to toUrl: URL) throws { + try fileManager.moveItem(at: fromUrl, to: toUrl) + } + public func removeItem(atPath: String) throws { return try fileManager.removeItem(atPath: atPath) } diff --git a/SessionUtilitiesKit/Types/KeychainStorage.swift b/SessionUtilitiesKit/Types/KeychainStorage.swift index f9e60fbb2a..49a62e3e14 100644 --- a/SessionUtilitiesKit/Types/KeychainStorage.swift +++ b/SessionUtilitiesKit/Types/KeychainStorage.swift @@ -2,7 +2,7 @@ // // stringlint:disable -import Foundation +import UIKit import KeychainSwift // MARK: - Singleton @@ -10,7 +10,7 @@ import KeychainSwift public extension Singleton { static let keychain: SingletonConfig = Dependencies.create( identifier: "keychain", - createInstance: { _ in KeychainStorage() } + createInstance: { dependencies in KeychainStorage(using: dependencies) } ) } @@ -23,11 +23,15 @@ public extension Log.Category { // MARK: - KeychainStorageError public enum KeychainStorageError: Error { + case keySpecInvalid + case keySpecCreationFailed + case keySpecInaccessible case failure(code: Int32?, logCategory: Log.Category, description: String) public var code: Int32? { switch self { case .failure(let code, _, _): return code + default: return nil } } } @@ -46,11 +50,35 @@ public protocol KeychainStorageType: AnyObject { func removeAll() throws func migrateLegacyKeyIfNeeded(legacyKey: String, legacyService: String?, toKey key: KeychainStorage.DataKey) throws + @discardableResult func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data +} + +public extension KeychainStorageType { + @discardableResult func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category + ) throws -> Data { + return try getOrGenerateEncryptionKey( + forKey: key, + length: length, + cat: cat, + legacyKey: nil, + legacyService: nil + ) + } } // MARK: - KeychainStorage public class KeychainStorage: KeychainStorageType { + private let dependencies: Dependencies private let keychain: KeychainSwift = { let result: KeychainSwift = KeychainSwift() result.synchronizable = false // This is the default but better to be explicit @@ -58,6 +86,14 @@ public class KeychainStorage: KeychainStorageType { return result }() + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - Functions + public func string(forKey key: KeychainStorage.StringKey) throws -> String { guard let result: String = keychain.get(key.rawValue) else { throw KeychainStorageError.failure( @@ -170,6 +206,62 @@ public class KeychainStorage: KeychainStorageType { // Remove the data from the old location SecItemDelete(query as CFDictionary) } + + @discardableResult public func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data { + do { + if let legacyKey: String = legacyKey { + try? migrateLegacyKeyIfNeeded( + legacyKey: legacyKey, + legacyService: legacyService, + toKey: key + ) + } + + var encryptionKey: Data = try data(forKey: key) + defer { encryptionKey.resetBytes(in: 0..: DependenciesSettable { ) } + internal func getExpectation(funcName: String = #function, args: [Any?] = [], untrackedArgs: [Any?] = []) -> MockFunction? { + return functionConsumer.getExpectation( + funcName, + parameterCount: args.count, + parameterSummary: summary(for: args), + allParameterSummaryCombinations: summaries(for: args), + args: args, + untrackedArgs: untrackedArgs + ) + } + // MARK: - Functions internal func reset() { @@ -290,6 +301,7 @@ internal class MockFunction { var untrackedArgs: [Any?]? var actions: [([Any?], [Any?]) -> Void] var returnError: (any Error)? + var closureCallArgs: [Any?] var returnValue: Any? var dynamicReturnValueRetriever: (([Any?], [Any?]) -> Any?)? @@ -302,6 +314,7 @@ internal class MockFunction { untrackedArgs: [Any?], actions: [([Any?], [Any?]) -> Void], returnError: (any Error)?, + closureCallArgs: [Any?], returnValue: Any?, dynamicReturnValueRetriever: (([Any?], [Any?]) -> Any?)? ) { @@ -311,6 +324,7 @@ internal class MockFunction { self.allParameterSummaryCombinations = allParameterSummaryCombinations self.actions = actions self.returnError = returnError + self.closureCallArgs = closureCallArgs self.returnValue = returnValue self.dynamicReturnValueRetriever = dynamicReturnValueRetriever } @@ -328,6 +342,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { private var args: [Any?]? private var untrackedArgs: [Any?]? private var actions: [([Any?], [Any?]) -> Void] = [] + private var closureCallArgs: [Any?] = [] private var returnValue: R? private var dynamicReturnValueRetriever: (([Any?], [Any?]) -> R?)? private var returnError: Error? @@ -357,6 +372,10 @@ internal class MockFunctionBuilder: MockFunctionHandler { return self } + func withClosureCallArgs(_ values: [Any?]) { + closureCallArgs = values + } + func thenReturn(_ value: R?) { returnValue = value } @@ -501,6 +520,7 @@ internal class MockFunctionBuilder: MockFunctionHandler { untrackedArgs: untrackedArgs, actions: actions, returnError: returnError, + closureCallArgs: closureCallArgs, returnValue: returnValue, dynamicReturnValueRetriever: dynamicReturnValueRetriever.map { closure in { args, untrackedArgs in closure(args, untrackedArgs) } @@ -558,14 +578,14 @@ internal class FunctionConsumer: MockFunctionHandler { var functionHandlers: [Key: [String: MockFunction]] = [:] @ThreadSafeObject var calls: [Key: [CallDetails]] = [:] - private func getExpectation( + fileprivate func getExpectation( _ functionName: String, parameterCount: Int, parameterSummary: String, allParameterSummaryCombinations: [ParameterCombination], args: [Any?], untrackedArgs: [Any?] - ) -> MockFunction { + ) -> MockFunction? { let key: Key = Key(name: functionName, paramCount: parameterCount) if !functionBuilders.isEmpty { @@ -591,7 +611,32 @@ internal class FunctionConsumer: MockFunctionHandler { functionBuilders.removeAll() } - guard let expectation: MockFunction = firstFunction(for: key, matchingParameterSummaryIfPossible: parameterSummary, allParameterSummaryCombinations: allParameterSummaryCombinations) else { + return firstFunction( + for: key, + matchingParameterSummaryIfPossible: parameterSummary, + allParameterSummaryCombinations: allParameterSummaryCombinations + ) + } + + private func getAndTrackExpectation( + _ functionName: String, + parameterCount: Int, + parameterSummary: String, + allParameterSummaryCombinations: [ParameterCombination], + args: [Any?], + untrackedArgs: [Any?] + ) -> MockFunction { + let key: Key = Key(name: functionName, paramCount: parameterCount) + let maybeExpectation: MockFunction? = getExpectation( + functionName, + parameterCount: parameterCount, + parameterSummary: parameterSummary, + allParameterSummaryCombinations: allParameterSummaryCombinations, + args: args, + untrackedArgs: untrackedArgs + ) + + guard let expectation: MockFunction = maybeExpectation else { preconditionFailure("No expectations found for \(functionName)") } @@ -622,7 +667,7 @@ internal class FunctionConsumer: MockFunctionHandler { args: [Any?], untrackedArgs: [Any?] ) -> Output { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, @@ -654,7 +699,7 @@ internal class FunctionConsumer: MockFunctionHandler { args: [Any?], untrackedArgs: [Any?] ) { - _ = getExpectation( + _ = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, @@ -672,7 +717,7 @@ internal class FunctionConsumer: MockFunctionHandler { args: [Any?], untrackedArgs: [Any?] ) throws -> Output { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, @@ -705,7 +750,7 @@ internal class FunctionConsumer: MockFunctionHandler { args: [Any?], untrackedArgs: [Any?] ) throws { - let expectation: MockFunction = getExpectation( + let expectation: MockFunction = getAndTrackExpectation( functionName, parameterCount: parameterCount, parameterSummary: parameterSummary, diff --git a/_SharedTestUtilities/MockFileManager.swift b/_SharedTestUtilities/MockFileManager.swift index 61c9300981..ef8e20bdc1 100644 --- a/_SharedTestUtilities/MockFileManager.swift +++ b/_SharedTestUtilities/MockFileManager.swift @@ -56,6 +56,8 @@ class MockFileManager: Mock, FileManagerType { func contents(atPath: String) -> Data? { return mock(args: [atPath]) } func contentsOfDirectory(at url: URL) throws -> [URL] { return mock(args: [url]) } func contentsOfDirectory(atPath path: String) throws -> [String] { return mock(args: [path]) } + func isDirectoryEmpty(at url: URL) -> Bool { return mock(args: [url]) } + func isDirectoryEmpty(atPath path: String) -> Bool { return mock(args: [path]) } func createFile(atPath: String, contents: Data?, attributes: [FileAttributeKey : Any]?) -> Bool { return mock(args: [atPath, contents, attributes]) @@ -71,6 +73,8 @@ class MockFileManager: Mock, FileManagerType { func copyItem(atPath: String, toPath: String) throws { return try mockThrowing(args: [atPath, toPath]) } func copyItem(at fromUrl: URL, to toUrl: URL) throws { return try mockThrowing(args: [fromUrl, toUrl]) } + func moveItem(atPath: String, toPath: String) throws { return try mockThrowing(args: [atPath, toPath]) } + func moveItem(at fromUrl: URL, to toUrl: URL) throws { return try mockThrowing(args: [fromUrl, toUrl]) } func removeItem(atPath: String) throws { return try mockThrowing(args: [atPath]) } func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { diff --git a/_SharedTestUtilities/MockGeneralCache.swift b/_SharedTestUtilities/MockGeneralCache.swift index 3531fbb067..63197a655e 100644 --- a/_SharedTestUtilities/MockGeneralCache.swift +++ b/_SharedTestUtilities/MockGeneralCache.swift @@ -4,11 +4,26 @@ import UIKit import SessionUtilitiesKit class MockGeneralCache: Mock, GeneralCacheType { + var userExists: Bool { + get { return mock() } + set { mockNoReturn(args: [newValue]) } + } + var sessionId: SessionId { get { return mock() } set { mockNoReturn(args: [newValue]) } } + var ed25519Seed: [UInt8] { + get { return mock() } + set { mockNoReturn(args: [newValue]) } + } + + var ed25519SecretKey: [UInt8] { + get { return mock() } + set { mockNoReturn(args: [newValue]) } + } + var recentReactionTimestamps: [Int64] { get { return (mock() ?? []) } set { mockNoReturn(args: [newValue]) } @@ -24,7 +39,7 @@ class MockGeneralCache: Mock, GeneralCacheType { set { mockNoReturn(args: [newValue]) } } - func setCachedSessionId(sessionId: SessionId) { - mockNoReturn(args: [sessionId]) + func setSecretKey(ed25519SecretKey: [UInt8]) { + mockNoReturn(args: [ed25519SecretKey]) } } diff --git a/_SharedTestUtilities/MockKeychain.swift b/_SharedTestUtilities/MockKeychain.swift index 1790334404..8b92b49522 100644 --- a/_SharedTestUtilities/MockKeychain.swift +++ b/_SharedTestUtilities/MockKeychain.swift @@ -33,5 +33,14 @@ class MockKeychain: Mock, KeychainStorageType { func migrateLegacyKeyIfNeeded(legacyKey: String, legacyService: String?, toKey key: KeychainStorage.DataKey) throws { try mockThrowingNoReturn(args: [legacyKey, legacyService, key]) } + + func getOrGenerateEncryptionKey( + forKey key: KeychainStorage.DataKey, + length: Int, + cat: Log.Category, + legacyKey: String?, + legacyService: String? + ) throws -> Data { + return try mockThrowing(args: [key, length, cat, legacyKey, legacyService]) + } } - diff --git a/_SharedTestUtilities/Mocked.swift b/_SharedTestUtilities/Mocked.swift index 2b3337b6ba..eee108a77f 100644 --- a/_SharedTestUtilities/Mocked.swift +++ b/_SharedTestUtilities/Mocked.swift @@ -67,6 +67,10 @@ extension Database: Mocked { } } +extension URL: Mocked { + static var mock: URL = URL(fileURLWithPath: "mock") +} + extension URLRequest: Mocked { static var mock: URLRequest = URLRequest(url: URL(fileURLWithPath: "mock")) } @@ -104,6 +108,10 @@ extension FileProtectionType: Mocked { static var mock: FileProtectionType = .complete } +extension Log.Category: Mocked { + static var mock: Log.Category = .create("mock", defaultLevel: .debug) +} + // MARK: - Encodable Convenience extension Mocked where Self: Encodable {