diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 95784c3167..28cee4c09a 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -801,13 +801,13 @@ FD848B9628422A2A000E298B /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B86283B844B000E298B /* MessageViewModel.swift */; }; FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9728422F1A000E298B /* Date+Utilities.swift */; }; FD848B9A28442CE6000E298B /* StorageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD848B9928442CE6000E298B /* StorageError.swift */; }; - FD860CBC2D6E7A9F00BBE29C /* _024_FixBustedInteractionVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */; }; - FD860CBE2D6E7DAA00BBE29C /* DeveloperSettingsViewModel+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */; }; - FD860CC92D6ED2ED00BBE29C /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD860CC82D6ED2ED00BBE29C /* DifferenceKit */; }; FD860CB42D668FD300BBE29C /* AppearanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB32D668FD000BBE29C /* AppearanceViewModel.swift */; }; FD860CB62D66913F00BBE29C /* ThemePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */; }; FD860CB82D66BC9900BBE29C /* AppIconViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB72D66BC9500BBE29C /* AppIconViewModel.swift */; }; FD860CBA2D66BF2A00BBE29C /* AppIconGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CB92D66BF2300BBE29C /* AppIconGridView.swift */; }; + FD860CBC2D6E7A9F00BBE29C /* _024_FixBustedInteractionVariant.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */; }; + FD860CBE2D6E7DAA00BBE29C /* DeveloperSettingsViewModel+Testing.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */; }; + FD860CC92D6ED2ED00BBE29C /* DifferenceKit in Frameworks */ = {isa = PBXBuildFile; productRef = FD860CC82D6ED2ED00BBE29C /* DifferenceKit */; }; FD86FDA32BC5020600EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; }; FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; }; FD86FDA52BC51C5500EC251B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */; }; @@ -833,6 +833,7 @@ FD9DD2722A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FD9DD2732A72516D00ECB68E /* TestExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD9DD2702A72516D00ECB68E /* TestExtensions.swift */; }; FDA335F52D91157A007E0EB6 /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA335F42D911576007E0EB6 /* AnimatedImageView.swift */; }; + FDA3B2842D9DEA95007E0EB6 /* ExtensionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA3B2832D9DEA8F007E0EB6 /* ExtensionHelper.swift */; }; FDAA16762AC28A3B00DDBF77 /* UserDefaultsType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */; }; FDAA167B2AC28E2F00DDBF77 /* SnodeRequestSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167A2AC28E2F00DDBF77 /* SnodeRequestSpec.swift */; }; FDAA167D2AC528A200DDBF77 /* Preferences+Sound.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAA167C2AC528A200DDBF77 /* Preferences+Sound.swift */; }; @@ -1990,12 +1991,12 @@ FD859EEF27BF207700510D0C /* SessionProtos.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = SessionProtos.proto; sourceTree = ""; }; FD859EF027BF207C00510D0C /* WebSocketResources.proto */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.protobuf; path = WebSocketResources.proto; sourceTree = ""; }; FD859EF127BF6BA200510D0C /* Data+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Utilities.swift"; sourceTree = ""; }; - FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _024_FixBustedInteractionVariant.swift; sourceTree = ""; }; - FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeveloperSettingsViewModel+Testing.swift"; sourceTree = ""; }; FD860CB32D668FD000BBE29C /* AppearanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceViewModel.swift; sourceTree = ""; }; FD860CB52D66913B00BBE29C /* ThemePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePreviewView.swift; sourceTree = ""; }; FD860CB72D66BC9500BBE29C /* AppIconViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconViewModel.swift; sourceTree = ""; }; FD860CB92D66BF2300BBE29C /* AppIconGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconGridView.swift; sourceTree = ""; }; + FD860CBB2D6E7A9400BBE29C /* _024_FixBustedInteractionVariant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _024_FixBustedInteractionVariant.swift; sourceTree = ""; }; + FD860CBD2D6E7DA000BBE29C /* DeveloperSettingsViewModel+Testing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DeveloperSettingsViewModel+Testing.swift"; sourceTree = ""; }; FD86FDA22BC5020600EC251B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; FD87DCF928B74DB300AF0F98 /* ConversationSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSettingsViewModel.swift; sourceTree = ""; }; FD87DCFD28B7582C00AF0F98 /* BlockedContactsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedContactsViewModel.swift; sourceTree = ""; }; @@ -2014,6 +2015,7 @@ FD9AECA42AAA9609009B3406 /* NotificationResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationResolution.swift; sourceTree = ""; }; FD9DD2702A72516D00ECB68E /* TestExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExtensions.swift; sourceTree = ""; }; FDA335F42D911576007E0EB6 /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; + FDA3B2832D9DEA8F007E0EB6 /* ExtensionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionHelper.swift; sourceTree = ""; }; FDAA16752AC28A3B00DDBF77 /* UserDefaultsType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsType.swift; sourceTree = ""; }; 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 = ""; }; @@ -3389,6 +3391,7 @@ C38EF309255B6DBE007E1867 /* DeviceSleepManager.swift */, FD4C4E9B2B02E2A300C72199 /* DisplayPictureError.swift */, FD2273072C353109004D8A6C /* DisplayPictureManager.swift */, + FDA3B2832D9DEA8F007E0EB6 /* ExtensionHelper.swift */, C3BBE0C62554F1570050F1E3 /* FixedWidthInteger+BigEndian.swift */, C3A71D0A2558989C0043A11F /* MessageWrapper.swift */, C38EF2F5255B6DBC007E1867 /* OWSAudioPlayer.h */, @@ -6269,6 +6272,7 @@ FDF8488029405994007DCAE5 /* HTTPQueryParam+OpenGroup.swift in Sources */, FD09798527FD1A6500936362 /* ClosedGroupKeyPair.swift in Sources */, FD245C632850664600B966DD /* Configuration.swift in Sources */, + FDA3B2842D9DEA95007E0EB6 /* ExtensionHelper.swift in Sources */, FD2272FF2C352D8E004D8A6C /* LibSession+UserProfile.swift in Sources */, FD5C7305284F0FF30029977D /* MessageReceiver+VisibleMessages.swift in Sources */, FDB5DAE82A95D96C002C8721 /* MessageReceiver+Groups.swift in Sources */, diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 4cceed26c5..c17a8d4bd6 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1850,7 +1850,8 @@ extension ConversationVC: successfullyAddedGroup: successfullyAddedGroup, roomToken: room, server: server, - publicKey: publicKey + publicKey: publicKey, + joinedAt: TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index 28abcc310b..882e0f69b5 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -278,12 +278,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD dependencies[defaults: .appGroup, key: .isMainAppActive] = true // FIXME: Seems like there are some discrepancies between the expectations of how the iOS lifecycle methods work, we should look into them and ensure the code behaves as expected (in this case there were situations where these two wouldn't get called when returning from the background) - dependencies[singleton: .storage].resumeDatabaseAccess() - dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } - - ensureRootViewController(calledFrom: .didBecomeActive) + dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [weak self, dependencies] in + /// Don't access `storage` until the application is ready to avoid creating an empty database before the `ExtensionHelper` + /// migration has completed + dependencies[singleton: .storage].resumeDatabaseAccess() + dependencies.mutate(cache: .libSessionNetwork) { $0.resumeNetworkAccess() } + + self?.ensureRootViewController(calledFrom: .didBecomeActive) - dependencies[singleton: .appReadiness].runNowOrWhenAppDidBecomeReady { [weak self] in self?.handleActivation() /// Clear all notifications whenever we become active once the app is ready @@ -295,14 +297,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD DispatchQueue.main.async { self?.clearAllNotificationsAndRestoreBadgeCount() } + + /// Don't access `storage` until the application is ready to avoid creating an empty database before the `ExtensionHelper` + /// migration has completed + if dependencies[singleton: .storage, key: .areCallsEnabled] && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] { + Permissions.checkLocalNetworkPermission(using: dependencies) + } } // On every activation, clear old temp directories. dependencies[singleton: .fileManager].clearOldTemporaryDirectories() - - if dependencies[singleton: .storage, key: .areCallsEnabled] && dependencies[defaults: .standard, key: .hasRequestedLocalNetworkPermission] { - Permissions.checkLocalNetworkPermission(using: dependencies) - } } func applicationWillResignActive(_ application: UIApplication) { diff --git a/Session/Onboarding/Onboarding.swift b/Session/Onboarding/Onboarding.swift index a43e3e2f38..87709309e3 100644 --- a/Session/Onboarding/Onboarding.swift +++ b/Session/Onboarding/Onboarding.swift @@ -244,7 +244,7 @@ extension Onboarding { cache.loadDefaultStateFor( variant: .userProfile, sessionId: userSessionId, - userEd25519KeyPair: identity.ed25519KeyPair, + userEd25519SecretKey: identity.ed25519KeyPair.secretKey, groupEd25519SecretKey: nil ) try cache.unsafeDirectMergeConfigMessage( @@ -383,10 +383,6 @@ extension Onboarding { ) } - /// Now that the onboarding process is completed we can enable the Share and Notification extensions (prior to - /// this point the account is in an invalid state so there is no point enabling them) - db[.isReadyForAppExtensions] = true - /// Now that everything is saved we should update the `Onboarding.Cache` `state` to be `completed` (we do /// this within the db write query because then `updateAllAndConfig` below will trigger a config sync which is /// dependant on this `state` being updated) @@ -407,6 +403,13 @@ extension Onboarding { } } + /// Now that the onboarding process is completed we can store the `UserMetadata` for the Share and Notification + /// extensions (prior to this point the account is in an invalid state so they can't be used) + dependencies[singleton: .extensionHelper].saveUserMetadata( + sessionId: userSessionId, + ed25519SecretKey: ed25519KeyPair.secretKey + ) + /// Store whether the user wants to use APNS dependencies[defaults: .standard, key: .isUsingFullAPNs] = useAPNS diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 00ef3b1183..d71fbfe785 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -221,7 +221,8 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC successfullyAddedGroup: successfullyAddedGroup, roomToken: roomToken, server: server, - publicKey: publicKey + publicKey: publicKey, + joinedAt: TimeInterval(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000) ) } .subscribe(on: DispatchQueue.global(qos: .userInitiated)) diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index c289f7f372..660a4ba88c 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -1116,7 +1116,7 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, } private func copyDatabasePath() { - UIPasteboard.general.string = Storage.sharedDatabaseDirectoryPath + UIPasteboard.general.string = Storage.databaseDirectoryPath showToast( text: "copied".localized(), diff --git a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift index d8d090bf9e..eead0ff231 100644 --- a/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift +++ b/SessionMessagingKit/Crypto/Crypto+SessionMessagingKit.swift @@ -37,33 +37,66 @@ public extension Crypto.Generator { default: throw MessageSenderError.signingFailed } }() - - var cPlaintext: [UInt8] = Array(plaintext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey - var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) - var maybeCiphertext: UnsafeMutablePointer? = nil - var ciphertextLen: Int = 0 - - guard - cEd25519SecretKey.count == 64, - cDestinationPubKey.count == 32, - session_encrypt_for_recipient_deterministic( - &cPlaintext, - cPlaintext.count, - &cEd25519SecretKey, - &cDestinationPubKey, - &maybeCiphertext, - &ciphertextLen - ), - ciphertextLen > 0, - let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) - else { throw MessageSenderError.encryptionFailed } - - maybeCiphertext?.deallocate() - - return ciphertext + + return try ciphertextWithSessionProtocol( + plaintext: plaintext, + destinationX25519PublicKey: Array(destinationX25519PublicKey), + ed25519SecretKey: ed25519KeyPair.secretKey, + using: dependencies + ) + } + } + + static func ciphertextWithSessionProtocol( + plaintext: Data, + destinationX25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8], + using dependencies: Dependencies + ) -> Crypto.Generator { + return Crypto.Generator( + id: "ciphertextWithSessionProtocol", + args: [plaintext, destinationX25519PublicKey, ed25519SecretKey] + ) { + return try ciphertextWithSessionProtocol( + plaintext: plaintext, + destinationX25519PublicKey: destinationX25519PublicKey, + ed25519SecretKey: ed25519SecretKey, + using: dependencies + ) } } + + private static func ciphertextWithSessionProtocol( + plaintext: Data, + destinationX25519PublicKey: [UInt8], + ed25519SecretKey: [UInt8], + using dependencies: Dependencies + ) throws -> Data { + var cPlaintext: [UInt8] = Array(plaintext) + var cEd25519SecretKey: [UInt8] = ed25519SecretKey + var cDestinationPubKey: [UInt8] = Array(destinationX25519PublicKey) + var maybeCiphertext: UnsafeMutablePointer? = nil + var ciphertextLen: Int = 0 + + guard + cEd25519SecretKey.count == 64, + cDestinationPubKey.count == 32, + session_encrypt_for_recipient_deterministic( + &cPlaintext, + cPlaintext.count, + &cEd25519SecretKey, + &cDestinationPubKey, + &maybeCiphertext, + &ciphertextLen + ), + ciphertextLen > 0, + let ciphertext: Data = maybeCiphertext.map({ Data(bytes: $0, count: ciphertextLen) }) + else { throw MessageSenderError.encryptionFailed } + + maybeCiphertext?.deallocate() + + return ciphertext + } static func ciphertextWithMultiEncrypt( messages: [Data], @@ -108,6 +141,35 @@ public extension Crypto.Generator { return try encryptedData ?? { throw MessageSenderError.encryptionFailed }() } } + + 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 } + + maybeCiphertext?.deallocate() + + return ciphertext + } + } } // MARK: - Decryption @@ -125,32 +187,61 @@ public extension Crypto.Generator { let ed25519KeyPair: KeyPair = try Identity.fetchUserEd25519KeyPair(db) ?? { throw MessageSenderError.noUserED25519KeyPair }() - - var cCiphertext: [UInt8] = Array(ciphertext) - var cEd25519SecretKey: [UInt8] = ed25519KeyPair.secretKey - var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) - var maybePlaintext: UnsafeMutablePointer? = nil - var plaintextLen: Int = 0 - - guard - cEd25519SecretKey.count == 64, - session_decrypt_incoming( - &cCiphertext, - cCiphertext.count, - &cEd25519SecretKey, - &cSenderSessionId, - &maybePlaintext, - &plaintextLen - ), - plaintextLen > 0, - let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) - else { throw MessageReceiverError.decryptionFailed } - - maybePlaintext?.deallocate() - - return (plaintext, String(cString: cSenderSessionId)) + + return try plaintextWithSessionProtocol( + ciphertext: ciphertext, + ed25519SecretKey: ed25519KeyPair.secretKey, + using: dependencies + ) + } + } + + static func plaintextWithSessionProtocol( + ciphertext: Data, + ed25519SecretKey: [UInt8], + using dependencies: Dependencies + ) -> Crypto.Generator<(plaintext: Data, senderSessionIdHex: String)> { + return Crypto.Generator( + id: "plaintextWithSessionProtocol", + args: [ciphertext, ed25519SecretKey] + ) { + try plaintextWithSessionProtocol( + ciphertext: ciphertext, + ed25519SecretKey: ed25519SecretKey, + using: dependencies + ) } } + + private static func plaintextWithSessionProtocol( + ciphertext: Data, + ed25519SecretKey: [UInt8], + using dependencies: Dependencies + ) throws -> (plaintext: Data, senderSessionIdHex: String) { + var cCiphertext: [UInt8] = Array(ciphertext) + var cEd25519SecretKey: [UInt8] = ed25519SecretKey + var cSenderSessionId: [CChar] = [CChar](repeating: 0, count: 67) + var maybePlaintext: UnsafeMutablePointer? = nil + var plaintextLen: Int = 0 + + guard + cEd25519SecretKey.count == 64, + session_decrypt_incoming( + &cCiphertext, + cCiphertext.count, + &cEd25519SecretKey, + &cSenderSessionId, + &maybePlaintext, + &plaintextLen + ), + plaintextLen > 0, + let plaintext: Data = maybePlaintext.map({ Data(bytes: $0, count: plaintextLen) }) + else { throw MessageReceiverError.decryptionFailed } + + maybePlaintext?.deallocate() + + return (plaintext, String(cString: cSenderSessionId)) + } static func plaintextWithSessionProtocolLegacyGroup( ciphertext: Data, @@ -330,4 +421,33 @@ public extension Crypto.Generator { return decryptedData } } + + 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 } + + maybePlaintext?.deallocate() + + return plaintext + } + } } diff --git a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift index 9667f5bd31..6b21519088 100644 --- a/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift +++ b/SessionMessagingKit/Database/Migrations/_014_GenerateInitialUserConfigDumps.swift @@ -386,13 +386,10 @@ enum _014_GenerateInitialUserConfigDumps: Migration { let threadId: String = info["threadId"] let pinnedPriority: Int32? = allThreads[threadId]?["pinnedPriority"] - return LibSession.CommunityInfo( - urlInfo: LibSession.OpenGroupUrlInfo( - threadId: threadId, - server: info["server"], - roomToken: info["roomToken"], - publicKey: info["publicKey"] - ), + return LibSession.CommunityUpdateInfo( + server: info["server"], + roomToken: info["roomToken"], + publicKey: info["publicKey"], priority: (pinnedPriority ?? 0) ) }, diff --git a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift index b3105424ff..24304a6ba5 100644 --- a/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift +++ b/SessionMessagingKit/Jobs/ConfigurationSyncJob.swift @@ -239,7 +239,10 @@ public enum ConfigurationSyncJob: JobExecutor { // Lastly we need to save the updated dumps to the database let updatedJob: Job? = dependencies[singleton: .storage].write { db in // Save the updated dumps to the database - try configDumps.forEach { try $0.upsert(db) } + try configDumps.forEach { dump in + try dump.upsert(db) + Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + } // When we complete the 'ConfigurationSync' job we want to immediately schedule // another one with a 'nextRunTimestamp' set to the 'maxRunFrequency' value to diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift index ba4e5380df..512516600e 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Contacts.swift @@ -44,7 +44,7 @@ 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( + let targetContactData: [String: LibSession.ContactData] = try LibSession.extractContacts( from: conf, serverTimestampMs: serverTimestampMs, using: dependencies @@ -761,18 +761,20 @@ extension LibSession { // MARK: - ContactData -private struct ContactData { - let contact: Contact - let profile: Profile - let config: DisappearingMessagesConfiguration - let priority: Int32 - let created: TimeInterval +extension LibSession { + internal struct ContactData { + let contact: Contact + let profile: Profile + let config: DisappearingMessagesConfiguration + let priority: Int32 + let created: TimeInterval + } } // MARK: - Convenience -private extension LibSession { - static func extractContacts( +extension LibSession { + internal static func extractContacts( from conf: UnsafeMutablePointer?, serverTimestampMs: Int64, using dependencies: Dependencies diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift index 9188e5e8a4..9271dd8d21 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+Shared.swift @@ -140,10 +140,12 @@ internal extension LibSession { try cache.performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in try LibSession.upsert( communities: threads - .compactMap { thread -> CommunityInfo? in + .compactMap { thread -> CommunityUpdateInfo? in urlInfo[thread.id].map { urlInfo in - CommunityInfo( - urlInfo: urlInfo, + CommunityUpdateInfo( + server: urlInfo.server, + roomToken: urlInfo.roomToken, + publicKey: urlInfo.publicKey, priority: thread.pinnedPriority .map { Int32($0 == 0 ? LibSession.visiblePriority : max($0, 1)) } .defaulting(to: LibSession.visiblePriority) diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift index 254b88007c..e69cb79506 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+SharedGroup.swift @@ -314,12 +314,14 @@ internal extension LibSession { // Create and save dumps for the configs try dependencies.mutate(cache: .libSession) { cache in try groupState.forEach { variant, config in - try cache.createDump( + let dump: ConfigDump? = try cache.createDump( config: config, for: variant, sessionId: SessionId(.group, hex: group.id), timestampMs: Int64(floor(group.formationTimestamp * 1000)) - )?.upsert(db) + ) + try dump?.upsert(db) + Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } } } diff --git a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift index e959f717d3..6e1b8eb07b 100644 --- a/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift +++ b/SessionMessagingKit/LibSession/Config Handling/LibSession+UserGroups.swift @@ -74,9 +74,9 @@ internal extension LibSessionCacheType { extractedUserGroups.communities.forEach { community in let successfullyAddedGroup: Bool = dependencies[singleton: .openGroupManager].add( db, - roomToken: community.data.roomToken, - server: community.data.server, - publicKey: community.data.publicKey, + roomToken: community.roomToken, + server: community.server, + publicKey: community.publicKey, forceVisible: true ) @@ -85,9 +85,10 @@ internal extension LibSessionCacheType { dependencies[singleton: .openGroupManager].performInitialRequestsAfterAdd( queue: DispatchQueue.global(qos: .userInitiated), successfullyAddedGroup: successfullyAddedGroup, - roomToken: community.data.roomToken, - server: community.data.server, - publicKey: community.data.publicKey + roomToken: community.roomToken, + server: community.server, + publicKey: community.publicKey, + joinedAt: community.joinedAt ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .sinkUntilComplete() @@ -96,9 +97,9 @@ internal extension LibSessionCacheType { // Set the priority if it's changed (new communities will have already been inserted at // this stage) - if existingThreadInfo[community.data.threadId]?.pinnedPriority != community.priority { + if existingThreadInfo[community.threadId]?.pinnedPriority != community.priority { _ = try? SessionThread - .filter(id: community.data.threadId) + .filter(id: community.threadId) .updateAllAndConfig( db, SessionThread.Columns.pinnedPriority.set(to: community.priority), @@ -111,7 +112,7 @@ internal extension LibSessionCacheType { let communityIdsToRemove: Set = Set(existingThreadInfo .filter { $0.value.variant == .community } .keys) - .subtracting(extractedUserGroups.communities.map { $0.data.threadId }) + .subtracting(extractedUserGroups.communities.map { $0.threadId }) if !communityIdsToRemove.isEmpty { LibSession.kickFromConversationUIIfNeeded(removedThreadIds: Array(communityIdsToRemove), using: dependencies) @@ -710,7 +711,7 @@ internal extension LibSession { } static func upsert( - communities: [CommunityInfo], + communities: [CommunityUpdateInfo], in config: Config? ) throws { guard case .userGroups(let conf) = config else { throw LibSessionError.invalidConfigObject } @@ -719,14 +720,14 @@ internal extension LibSession { try communities .forEach { community in guard - var cBaseUrl: [CChar] = community.urlInfo.server.cString(using: .utf8), - var cRoom: [CChar] = community.urlInfo.roomToken.cString(using: .utf8) + var cBaseUrl: [CChar] = community.server.cString(using: .utf8), + var cRoom: [CChar] = community.roomToken.cString(using: .utf8) else { Log.error(.libSession, "Unable to upsert community conversation to LibSession: \(LibSessionError.invalidCConversion)") throw LibSessionError.invalidCConversion } - var cPubkey: [UInt8] = Array(Data(hex: community.urlInfo.publicKey)) + var cPubkey: [UInt8] = Array(Data(hex: community.publicKey)) var userCommunity: ugroups_community_info = ugroups_community_info() guard user_groups_get_or_construct_community(conf, &userCommunity, &cBaseUrl, &cRoom, &cPubkey) else { @@ -739,6 +740,16 @@ internal extension LibSession { ) } + /// Assign the communtiy name + if let name: String = community.name { + // TODO: [DATABASE REFACTOR] Need to save the 'name' on the community (shouldn't be synced though, will need to be nullable) +// userCommunity.set(\.name, to: name) + } + + // TODO: [DATABASE REFACTOR] Need to save the 'permissions' on the community (shouldn't be synced though, will need to be nullable) + + // Store the updated group (can't be sure if we made any changes above) + userCommunity.joined_at = (community.joinedAt.map { Int64($0) } ?? userCommunity.joined_at) userCommunity.priority = (community.priority ?? userCommunity.priority) user_groups_set_community(conf, &userCommunity) } @@ -779,6 +790,37 @@ internal extension LibSession { return updated } + + @discardableResult static func updatingCommunities( + _ db: Database, + _ updated: [T], + using dependencies: Dependencies + ) throws -> [T] { + guard let updatedCommunities: [OpenGroup] = updated as? [OpenGroup] else { throw StorageError.generic } + + // Exclude legacy groups as they aren't managed via SessionUtil + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + // Apply the changes + try dependencies.mutate(cache: .libSession) { cache in + try cache.performAndPushChange(db, for: .userGroups, sessionId: userSessionId) { config in + try upsert( + communities: updatedCommunities.map { community -> CommunityUpdateInfo in + CommunityUpdateInfo( + server: community.server, + roomToken: community.roomToken, + publicKey: community.publicKey, + name: community.name, + permissions: community.permissions + ) + }, + in: config + ) + } + } + + return updated + } } // MARK: - External Outgoing Changes @@ -792,19 +834,20 @@ public extension LibSession { server: String, rootToken: String, publicKey: String, + name: String?, + joinedAt: TimeInterval, using dependencies: Dependencies ) throws { try dependencies.mutate(cache: .libSession) { cache in try cache.performAndPushChange(db, for: .userGroups, sessionId: dependencies[cache: .general].sessionId) { config in try LibSession.upsert( communities: [ - CommunityInfo( - urlInfo: OpenGroupUrlInfo( - threadId: OpenGroup.idFor(roomToken: rootToken, server: server), - server: server, - roomToken: rootToken, - publicKey: publicKey - ) + CommunityUpdateInfo( + server: server, + roomToken: rootToken, + publicKey: publicKey, + name: name, + joinedAt: joinedAt ) ], in: config @@ -1160,7 +1203,7 @@ public extension LibSession { public extension LibSession { typealias ExtractedUserGroups = ( - communities: [PrioritisedData], + communities: [LibSession.CommunityInfo], legacyGroups: [LibSession.LegacyGroupInfo], groups: [LibSession.GroupInfo] ) @@ -1170,7 +1213,7 @@ public extension LibSession { using dependencies: Dependencies ) throws -> ExtractedUserGroups { var infiniteLoopGuard: Int = 0 - var communities: [PrioritisedData] = [] + var communities: [LibSession.CommunityInfo] = [] var legacyGroups: [LibSession.LegacyGroupInfo] = [] var groups: [LibSession.GroupInfo] = [] var community: ugroups_community_info = ugroups_community_info() @@ -1186,14 +1229,15 @@ public extension LibSession { let roomToken: String = community.get(\.room) communities.append( - PrioritisedData( - data: LibSession.OpenGroupUrlInfo( - threadId: OpenGroup.idFor(roomToken: roomToken, server: server), - server: server, - roomToken: roomToken, - publicKey: community.getHex(\.pubkey) - ), - priority: community.priority + LibSession.CommunityInfo( + server: server, + roomToken: roomToken, + publicKey: community.getHex(\.pubkey), + // TODO: [DATABASE REFACTOR] Need to expose the 'name' on the community (shouldn't be synced though, will need to be nullable) + name: "TESTOG",//community.get(\.name, nullIfEmpty: true), + priority: community.priority, + joinedAt: TimeInterval(community.get(\.joined_at)), + permissions: nil // TODO: [DATABASE REFACTOR] Need to expose these and store them ) ) } @@ -1362,17 +1406,64 @@ public extension LibSession { // MARK: - CommunityInfo -extension LibSession { +public extension LibSession { struct CommunityInfo { - let urlInfo: OpenGroupUrlInfo + let threadId: String + let server: String + let roomToken: String + let publicKey: String + let name: String? + let joinedAt: TimeInterval + let priority: Int32 + let permissions: OpenGroup.Permissions? + + init( + server: String, + roomToken: String, + publicKey: String, + name: String?, + priority: Int32, + joinedAt: TimeInterval, + permissions: OpenGroup.Permissions? + ) { + self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) + self.server = server + self.roomToken = roomToken + self.publicKey = publicKey + self.name = name + self.priority = priority + self.joinedAt = joinedAt + self.permissions = permissions + } + } + + struct CommunityUpdateInfo { + let threadId: String + let server: String + let roomToken: String + let publicKey: String + let name: String? let priority: Int32? + let joinedAt: TimeInterval? + let permissions: OpenGroup.Permissions? init( - urlInfo: OpenGroupUrlInfo, - priority: Int32? = nil + server: String, + roomToken: String, + publicKey: String, + name: String? = nil, + priority: Int32? = nil, + joinedAt: TimeInterval? = nil, + permissions: OpenGroup.Permissions? = nil ) { - self.urlInfo = urlInfo + self.threadId = OpenGroup.idFor(roomToken: roomToken, server: server) + self.server = server + self.roomToken = roomToken + self.publicKey = publicKey + self.name = name self.priority = priority + self.joinedAt = joinedAt + self.permissions = permissions } } } diff --git a/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift b/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift index efb9c13973..b4f6d31de2 100644 --- a/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift +++ b/SessionMessagingKit/LibSession/Database/QueryInterfaceRequest+Utilities.swift @@ -126,6 +126,9 @@ public extension QueryInterfaceRequest where RowDecoder: FetchableRecord & Table try LibSession.updatingGroups(db, updatedData, using: dependencies) return try LibSession.updatingGroupInfo(db, updatedData, using: dependencies) + case is QueryInterfaceRequest: + return try LibSession.updatingCommunities(db, updatedData, using: dependencies) + case is QueryInterfaceRequest: return try LibSession.updatingGroupMembers(db, updatedData, using: dependencies) diff --git a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift index e28083e940..c1df8ec485 100644 --- a/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift +++ b/SessionMessagingKit/LibSession/LibSession+SessionMessagingKit.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import SessionSnodeKit import SessionUtil @@ -196,6 +197,28 @@ public extension LibSession { public let userSessionId: SessionId public var isEmpty: Bool { configStore.isEmpty } + /// Store the conversation data extracted from the config messages in a `CurrentValueSubject` so all subscribers can + /// share the same data + /// + /// **Note:** To avoid wasting memory this value will be cleared while there are no remaining subscribers and will be + /// repopulated when it previous had no subscribers + private let _conversations: CurrentValueSubject<[SessionThreadViewModel], Never> = CurrentValueSubject([]) + private var activeSubscriptionsToConversations: Int = 0 + public var conversations: AnyPublisher<[SessionThreadViewModel], Never> { + _conversations + .handleEvents( + receiveSubscription: { [weak self] _ in + self?.activeSubscriptionsToConversations += 1 + self?.updateConversationDataIfNeeded(initialQuery: true) + }, + receiveCompletion: { [weak self] _ in + // TODO: [DATABASE REFACTOR] Need to test this logic + self?.activeSubscriptionsToConversations -= 1 + } + ) + .eraseToAnyPublisher() + } + // MARK: - Initialization public init(userSessionId: SessionId, using dependencies: Dependencies) { @@ -288,13 +311,13 @@ public extension LibSession { public func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) { configStore[sessionId, variant] = try? loadState( for: variant, sessionId: sessionId, - userEd25519SecretKey: userEd25519KeyPair.secretKey, + userEd25519SecretKey: userEd25519SecretKey, groupEd25519SecretKey: groupEd25519SecretKey, cachedData: nil ) @@ -544,12 +567,14 @@ public extension LibSession { // Only create a config dump if we need to if configNeedsDump(config) { - try createDump( + let dump: ConfigDump? = try createDump( config: config, for: variant, sessionId: sessionId, timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - )?.upsert(db) + ) + try dump?.upsert(db) + Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } } } catch { @@ -754,12 +779,19 @@ public extension LibSession { return } - try createDump( + let dump: ConfigDump? = try createDump( config: config, for: variant, sessionId: sessionId, timestampMs: latestServerTimestampMs - )?.upsert(db) + ) + try dump?.upsert(db) + Task { dependencies[singleton: .extensionHelper].replicate(dump: dump) } + + // Update the data stored in `_conversations` (if needed), we _should_ only + // need to do this if we needed to do a config dump as otherwise there shouldn't + // have been any changes + updateConversationDataIfNeeded() } catch { Log.error(.libSession, "Failed to process merge of \(variant) config data") @@ -875,6 +907,142 @@ public extension LibSession { return config.isAdmin() } + + /// This function extracts conversation data from the `configStore` and stores it in `_conversations` + /// + /// **Note:** This will only update the data if we have active `conversation` subscriptions + private func updateConversationDataIfNeeded(initialQuery: Bool = false) { + guard initialQuery || activeSubscriptionsToConversations == 1 else { return } + + // FIXME: This is pretty inefficient so might need to be reworked (unless we source directly from libSession before we need to do so) + do { + let userSessionId: SessionId = dependencies[cache: .general].sessionId + + guard + case .userProfile(let userProfile) = configStore[userSessionId, .userProfile], + case .contacts(let contactsConf) = configStore[userSessionId, .contacts], + case .userGroups(let userGroupsConf) = configStore[userSessionId, .userGroups], + case .convoInfoVolatile(let convoInfoVolatileConf) = configStore[userSessionId, .convoInfoVolatile] + else { throw LibSessionError.missingUserConfig } + + /// Extract main conversation data from configs + let contacts: [String: LibSession.ContactData] = try LibSession.extractContacts( + from: contactsConf, + serverTimestampMs: 0, + using: dependencies + ) + let extractedUserGroups: LibSession.ExtractedUserGroups = try LibSession.extractUserGroups( + from: userGroupsConf, + using: dependencies + ) + + /// Construct conversation data + var conversations: [SessionThreadViewModel] = [] + + /// Note to self conversation + conversations.append( + SessionThreadViewModel( + threadId: userSessionId.hexString, + threadVariant: .contact, + threadCreationDateTimestamp: 0, + threadIsNoteToSelf: true, + threadIsMessageRequest: false, + threadPinnedPriority: user_profile_get_nts_priority(userProfile), + threadIsBlocked: false, +// displayPictureFilename: "", // TODO: [DATABASE REFACTOR] Need to add this + using: dependencies + ) + ) + + /// Contact conversations (1-to-1) + conversations.append(contentsOf: contacts.values.map { contactData -> SessionThreadViewModel in + SessionThreadViewModel( + threadId: contactData.contact.id, + threadVariant: .contact, + threadCreationDateTimestamp: contactData.created, + threadIsMessageRequest: !contactData.contact.isApproved, + threadPinnedPriority: contactData.priority, + threadIsBlocked: contactData.contact.isBlocked, +// displayPictureFilename: "", // TODO: [DATABASE REFACTOR] Need to add this + contactProfile: contactData.profile, + using: dependencies + ) + }) + + /// Community conversations + conversations.append(contentsOf: extractedUserGroups.communities.map { community -> SessionThreadViewModel in + SessionThreadViewModel( + threadId: community.threadId, + threadVariant: .community, + threadCreationDateTimestamp: community.joinedAt, + threadIsMessageRequest: false, + threadPinnedPriority: community.priority, + threadIsBlocked: false, +// displayPictureFilename: "", // TODO: [DATABASE REFACTOR] Need to add this + openGroupName: community.name, + openGroupPermissions: community.permissions, + using: dependencies + ) + }) + + /// Group conversations + conversations.append(contentsOf: extractedUserGroups.groups.map { group -> SessionThreadViewModel in + // TODO: [DATABASE REFACTOR] Source the group name from groupInfo if present (instead of userGroups) + + // TODO: [DATABASE REFACTOR] Need to get group member info + // TODO: [DATABASE REFACTOR] Whether the current user is a member + // TODO: [DATABASE REFACTOR] Member profile info + // TODO: [DATABASE REFACTOR] Custom display pic + + SessionThreadViewModel( + threadId: group.groupSessionId, + threadVariant: .group, + threadCreationDateTimestamp: group.joinedAt, + threadIsMessageRequest: group.invited, + threadPinnedPriority: group.priority, + threadIsBlocked: false, +// displayPictureFilename: "", // TODO: [DATABASE REFACTOR] Need to add these +// closedGroupProfileFront: Profile? = nil, +// closedGroupProfileBack: Profile? = nil, +// closedGroupProfileBackFallback: Profile? = nil, +// closedGroupAdminProfile: Profile? = nil, + closedGroupName: group.name, +// currentUserIsClosedGroupMember: Bool? = nil, + currentUserIsClosedGroupAdmin: (group.groupIdentityPrivateKey != nil), +// threadCanWrite: (openGroupPermissions?.contains(.write) ?? false), + wasKickedFromGroup: group.wasKickedFromGroup, + groupIsDestroyed: group.wasGroupDestroyed, + using: dependencies + ) + }) + + /// Generate final conversation state and sort + let finalConversations: [SessionThreadViewModel] = conversations + .map { conversation -> SessionThreadViewModel in + conversation.populatingPostQueryData( + // TODO: [DATABASE REFACTOR] Need to generate these blinded ids without db access (store the capabilities in libSession as well?) + currentUserBlinded15SessionIdForThisThread: nil, + currentUserBlinded25SessionIdForThisThread: nil, + wasKickedFromGroup: (conversation.wasKickedFromGroup == true), + groupIsDestroyed: (conversation.groupIsDestroyed == true), + threadCanWrite: conversation.determineInitialCanWriteFlag(using: dependencies), + using: dependencies + ) + } + .sorted { lhs, rhs -> Bool in + // TODO: [DATABASE REFACTOR] Update sorting to be based on 'active_at' + guard lhs.lastInteractionDate == rhs.lastInteractionDate else { + return lhs.lastInteractionDate < rhs.lastInteractionDate + } + + // TODO: [DATABASE REFACTOR] Decide on the desired fallback behaviour for collisions + return lhs.displayName < rhs.displayName + } + + _conversations.send(finalConversations) + } + catch { Log.error(.libSession, "Unable to upate config conversation data due error: \(error)") } + } } } @@ -884,6 +1052,7 @@ public extension LibSession { public protocol LibSessionImmutableCacheType: ImmutableCacheType { var userSessionId: SessionId { get } var isEmpty: Bool { get } + var conversations: AnyPublisher<[SessionThreadViewModel], Never> { get } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool } @@ -901,7 +1070,7 @@ public protocol LibSessionCacheType: LibSessionImmutableCacheType, MutableCacheT func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool @@ -987,6 +1156,7 @@ private final class NoopLibSessionCache: LibSessionCacheType { let dependencies: Dependencies let userSessionId: SessionId = .invalid let isEmpty: Bool = true + var conversations: AnyPublisher<[SessionThreadViewModel], Never> { Just([]).eraseToAnyPublisher() } init(using dependencies: Dependencies) { self.dependencies = dependencies @@ -998,7 +1168,7 @@ private final class NoopLibSessionCache: LibSessionCacheType { func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) {} func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { return false } diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 41f2a4b2c9..829a887eae 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -214,7 +214,8 @@ public final class OpenGroupManager { successfullyAddedGroup: Bool, roomToken: String, server: String, - publicKey: String + publicKey: String, + joinedAt: TimeInterval ) -> AnyPublisher { // Only bother performing the initial request if the network isn't suspended guard @@ -253,6 +254,8 @@ public final class OpenGroupManager { server: server, rootToken: roomToken, publicKey: publicKey, + name: response.value.room.data.name, + joinedAt: joinedAt, using: dependencies ) diff --git a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift index 79900c13f7..a06b602815 100644 --- a/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift +++ b/SessionMessagingKit/Sending & Receiving/Notifications/PushNotificationAPI.swift @@ -10,7 +10,9 @@ import SessionUtilitiesKit // MARK: - KeychainStorage -public extension KeychainStorage.DataKey { static let pushNotificationEncryptionKey: Self = "PNEncryptionKeyKey" } +public extension KeychainStorage.DataKey { + static let pushNotificationEncryptionKey: Self = "PNEncryptionKeyKey" +} // MARK: - Log.Category @@ -216,9 +218,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( @@ -482,7 +490,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, @@ -513,54 +527,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.. = 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 { + public static var sharedExtensionCacheDirectoryPath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/extensionCache" } + private static var metadataPath: String { "\(ExtensionHelper.sharedExtensionCacheDirectoryPath)/metadata" } + private static func dumpFilePath(_ hash: [UInt8]) -> String { + return "\(ExtensionHelper.sharedExtensionCacheDirectoryPath)/\(hash.toHexString())" + } + private let encryptionKeyLength: Int = 32 + + private let dependencies: Dependencies + + // MARK: - Initialization + + init(using dependencies: Dependencies) { + self.dependencies = dependencies + } + + // MARK: - User Metadata + + public func saveUserMetadataIfNeeded(sessionId: SessionId, ed25519SecretKey: [UInt8]) { + /// Create the `UserMetadata` if needed + guard !dependencies[singleton: .fileManager].fileExists(atPath: ExtensionHelper.metadataPath) else { + return + } + + saveUserMetadata( + sessionId: sessionId, + ed25519SecretKey: ed25519SecretKey + ) + } + + public func saveUserMetadata(sessionId: SessionId, ed25519SecretKey: [UInt8]) { + let metadata: UserMetadata = UserMetadata( + sessionId: sessionId, + ed25519SecretKey: ed25519SecretKey + ) + + /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends + guard + let metadataAsData: Data = try? JSONEncoder(using: dependencies).encode(metadata), + var encKey: [UInt8] = (try? dependencies[singleton: .keychain] + .getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) + else { return } + defer { encKey.resetBytes(in: 0.. UserMetadata? { + /// Load in the data and `encKey` and reset the `encKey` as soon as the function ends + guard + let ciphertext: Data = dependencies[singleton: .fileManager] + .contents(atPath: ExtensionHelper.metadataPath), + var encKey: [UInt8] = (try? dependencies[singleton: .keychain] + .getOrGenerateEncryptionKey( + forKey: .extensionEncryptionKey, + length: encryptionKeyLength, + cat: .cat + )).map({ Array($0) }) + else { return nil } + defer { encKey.resetBytes(in: 0.. [UInt8]? { + return "\(sessionId.hexString)-\(variant)".data(using: .utf8).map { dataToHash in + dependencies[singleton: .crypto].generate( + .hash(message: Array(dataToHash)) + ) + } + } + + public func loadConfigState( + into cache: LibSession.Cache, + for sessionId: SessionId, + userSessionId: SessionId, + userEd25519SecretKey: [UInt8] + ) { + /// 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 { return } + defer { encKey.resetBytes(in: 0.., LibSessionCacheType { func loadDefaultStateFor( variant: ConfigDump.Variant, sessionId: SessionId, - userEd25519KeyPair: KeyPair, + userEd25519SecretKey: [UInt8], groupEd25519SecretKey: [UInt8]? ) { - mockNoReturn(args: [variant, sessionId, userEd25519KeyPair, groupEd25519SecretKey]) + mockNoReturn(args: [variant, sessionId, userEd25519SecretKey, groupEd25519SecretKey]) } func hasConfig(for variant: ConfigDump.Variant, sessionId: SessionId) -> Bool { diff --git a/SessionNotificationServiceExtension/NotificationServiceExtension.swift b/SessionNotificationServiceExtension/NotificationServiceExtension.swift index 265d72cadb..6858691e18 100644 --- a/SessionNotificationServiceExtension/NotificationServiceExtension.swift +++ b/SessionNotificationServiceExtension/NotificationServiceExtension.swift @@ -47,10 +47,10 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension /// notifications causing issues with new notifications self.dependencies = Dependencies.createEmpty() - // It's technically possible for 'completeSilently' to be called twice due to the NSE timeout so + /// It's technically possible for 'completeSilently' to be called twice due to the NSE timeout so self.hasCompleted = false - // Abort if the main app is running + /// Abort if the main app is running guard !dependencies[defaults: .appGroup, key: .isMainAppActive] else { return self.completeSilenty(.ignoreDueToMainAppRunning, requestId: request.identifier) } @@ -59,6 +59,14 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension return self.completeSilenty(.ignoreDueToNoContentFromApple, requestId: request.identifier) } + /// 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 let metadata: ExtensionHelper.UserMetadata = dependencies[singleton: .extensionHelper].loadUserMetadata() else { + return self.completeSilenty(.errorNotReadyForExtensions, requestId: request.identifier) + } + Log.info(.cat, "didReceive called with requestId: \(request.identifier).") /// Create the context if we don't have it (needed before _any_ interaction with the database) @@ -70,7 +78,7 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension } /// Actually perform the setup - self.performSetup(requestId: request.identifier) { [weak self] in + self.performSetup(metadata: metadata, requestId: request.identifier) { [weak self] in self?.handleNotification(notificationContent, requestId: request.identifier) } } @@ -387,70 +395,34 @@ public final class NotificationServiceExtension: UNNotificationServiceExtension // MARK: Setup - private func performSetup(requestId: String, completion: @escaping () -> Void) { + private func performSetup(metadata: ExtensionHelper.UserMetadata, requestId: String, completion: @escaping () -> Void) { Log.info(.cat, "Performing setup for requestId: \(requestId).") + // stringlint:ignore_start + Log.setup(with: Logger( + primaryPrefix: "NotificationServiceExtension", + customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/NotificationExtension", + using: dependencies + )) + LibSession.setupLogger(using: dependencies) + // stringlint:ignore_stop + + /// Setup Version Info and Network 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, - localizedFormatted: { helper, font in NSAttributedString() }, - localizedDeformatted: { helper in NSENotificationPresenter.localizedDeformatted(helper) }, - 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() - } - } - }, + + /// Configure the different targets + SNUtilitiesKit.configure( + networkMaxFileSize: Network.maxFileSize, + localizedFormatted: { helper, font in NSAttributedString() }, + localizedDeformatted: { helper in NSENotificationPresenter.localizedDeformatted(helper) }, using: dependencies ) + SNMessagingKit.configure(using: dependencies) + + /// The `NotificationServiceExtension` needs custom behaviours for it's notification presenter so set it up here + dependencies.set(singleton: .notificationsManager, to: NSENotificationPresenter(using: dependencies)) + + // TODO: [DATABASE REFACTOR] Need to load in the relevant config states } // MARK: Handle completion diff --git a/SessionShareExtension/SAEScreenLockViewController.swift b/SessionShareExtension/SAEScreenLockViewController.swift index 81d970a697..23cb1df192 100644 --- a/SessionShareExtension/SAEScreenLockViewController.swift +++ b/SessionShareExtension/SAEScreenLockViewController.swift @@ -9,11 +9,14 @@ final class SAEScreenLockViewController: ScreenLockViewController { private var hasShownAuthUIOnce: Bool = false private var isShowingAuthUI: Bool = false + private let hasUserMetadata: Bool private weak var shareViewDelegate: ShareViewDelegate? // MARK: - Initialization - init(shareViewDelegate: ShareViewDelegate) { + init(hasUserMetadata: Bool, shareViewDelegate: ShareViewDelegate) { + self.hasUserMetadata = hasUserMetadata + super.init() self.onUnlockPressed = { [weak self] in self?.unlockButtonWasTapped() } @@ -108,12 +111,12 @@ final class SAEScreenLockViewController: ScreenLockViewController { isShowingAuthUI = true ScreenLock.tryToUnlockScreenLock( - success: { [weak self] in + success: { [weak self, hasUserMetadata] in Log.assertOnMainThread() Log.info("unlock screen lock succeeded.") self?.isShowingAuthUI = false - self?.shareViewDelegate?.shareViewWasUnlocked() + self?.shareViewDelegate?.shareViewWasUnlocked(hasUserMetadata: hasUserMetadata) }, failure: { [weak self] error in Log.assertOnMainThread() diff --git a/SessionShareExtension/ShareNavController.swift b/SessionShareExtension/ShareNavController.swift index 6fe2605b13..4c8c1b8a4b 100644 --- a/SessionShareExtension/ShareNavController.swift +++ b/SessionShareExtension/ShareNavController.swift @@ -36,60 +36,83 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { /// to override it results in the share context crashing so ensure it doesn't exist first) if !dependencies[singleton: .appContext].isValid { dependencies.set(singleton: .appContext, to: ShareAppExtensionContext(rootViewController: self, using: dependencies)) - Dependencies.setIsRTLRetriever(requiresMainThread: false) { ShareAppExtensionContext.determineDeviceRTL() } + Dependencies.setIsRTLRetriever(requiresMainThread: false) { + ShareAppExtensionContext.determineDeviceRTL() + } } guard !SNUtilitiesKit.isRunningTests else { return } + // stringlint:ignore_start + Log.setup(with: Logger( + primaryPrefix: "SessionShareExtension", + customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/ShareExtension", + using: dependencies + )) + LibSession.setupLogger(using: dependencies) + // stringlint:ignore_stop + + /// Setup Version Info and Network dependencies.warmCache(cache: .appVersion) - - AppSetup.setupEnvironment( - additionalMigrationTargets: [DeprecatedUIKitMigrationTarget.self], - appSpecificBlock: { [dependencies] in - // stringlint:ignore_start - Log.setup(with: Logger( - primaryPrefix: "SessionShareExtension", - customDirectory: "\(dependencies[singleton: .fileManager].appSharedDataDirectoryPath)/Logs/ShareExtension", - using: dependencies - )) - // stringlint:ignore_stop - - // Setup LibSession - LibSession.setupLogger(using: dependencies) - dependencies.warmCache(cache: .libSessionNetwork) - - // Configure the different targets - SNUtilitiesKit.configure( - networkMaxFileSize: Network.maxFileSize, - localizedFormatted: { helper, font in SAESNUIKitConfig.localizedFormatted(helper, font) }, - localizedDeformatted: { helper in SAESNUIKitConfig.localizedDeformatted(helper) }, - using: dependencies - ) - SNMessagingKit.configure(using: dependencies) - }, - migrationsCompletion: { [weak self, dependencies] result in - switch result { - case .failure: Log.error("Failed to complete migrations") - case .success: - DispatchQueue.main.async { - /// Because the `SessionUIKit` target doesn't depend on the `SessionUtilitiesKit` dependency (it shouldn't - /// need to since it should just be UI) but since the theme settings are stored in the database we need to pass these through - /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) - SNUIKit.configure( - with: SAESNUIKitConfig(using: dependencies), - themeSettings: dependencies[singleton: .storage].read { db -> ThemeSettings in - (db[.theme], db[.themePrimaryColor], db[.themeMatchSystemDayNightCycle]) - } - ) - - self?.versionMigrationsDidComplete() - } - } - }, + dependencies.warmCache(cache: .libSessionNetwork) + + /// Configure the different targets + SNUtilitiesKit.configure( + networkMaxFileSize: Network.maxFileSize, + localizedFormatted: { helper, font in SAESNUIKitConfig.localizedFormatted(helper, font) }, + localizedDeformatted: { helper in SAESNUIKitConfig.localizedDeformatted(helper) }, using: dependencies ) + SNMessagingKit.configure(using: dependencies) + + /// Because the `SessionUIKit` target doesn't depend on the `SessionUtilitiesKit` dependency (it shouldn't + /// need to since it should just be UI) but since the theme settings are stored in the database we need to pass these through + /// to `SessionUIKit` and expose a mechanism to save updated settings - this is done here (once the migrations complete) + SNUIKit.configure( + with: SAESNUIKitConfig(using: dependencies), + themeSettings: dependencies[singleton: .storage].read { db -> ThemeSettings in + (db[.theme], db[.themePrimaryColor], db[.themeMatchSystemDayNightCycle]) + } + ) + + let maybeUserMetadata: ExtensionHelper.UserMetadata? = dependencies[singleton: .extensionHelper] + .loadUserMetadata() + + /// If we have `UserMetadata` then load the users `libSession` state + if let userMetadata: ExtensionHelper.UserMetadata = maybeUserMetadata { + /// 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: userMetadata.sessionId) + } + + /// Load the `libSession` state into memory + let cache: LibSession.Cache = LibSession.Cache( + userSessionId: userMetadata.sessionId, + using: dependencies + ) + dependencies[singleton: .extensionHelper].loadConfigState( + into: cache, + for: userMetadata.sessionId, + userSessionId: userMetadata.sessionId, + userEd25519SecretKey: userMetadata.ed25519SecretKey + ) + dependencies.set(cache: .libSession, to: cache) + } + + /// If the `appReadiness` wasn't marked as ready then do so now + /// + /// **Note:** that this does much more than set a flag; it will also run all deferred blocks. + if !dependencies[singleton: .appReadiness].isAppReady { + // TODO: [DATABASE REFACTOR] Is this needed? (this is a synchronous process so far) + dependencies[singleton: .appReadiness].setAppReady() + dependencies.mutate(cache: .appVersion) { $0.saeLaunchDidComplete() } + } // We don't need to use "screen protection" in the SAE. + /// Show the main app content now that everything is loaded + showMainContent(hasUserMetadata: maybeUserMetadata != nil) + + /// We don't need to use "screen protection" in the SAE. NotificationCenter.default.addObserver( self, selector: #selector(applicationDidEnterBackground), @@ -106,40 +129,6 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { ThemeManager.traitCollectionDidChange(previousTraitCollection) } - func versionMigrationsDidComplete() { - Log.assertOnMainThread() - - /// Now that the migrations are completed schedule config syncs for **all** configs that have pending changes to - /// ensure that any pending local state gets pushed and any jobs waiting for a successful config sync are run - /// - /// **Note:** We only want to do this if the app is active and ready for app extensions to run - if dependencies[singleton: .appContext].isAppForegroundAndActive && dependencies[singleton: .storage, key: .isReadyForAppExtensions] { - dependencies[singleton: .storage].writeAsync { [dependencies] db in - dependencies.mutate(cache: .libSession) { $0.syncAllPendingChanges(db) } - } - } - - checkIsAppReady(migrationsCompleted: true) - } - - func checkIsAppReady(migrationsCompleted: Bool) { - Log.assertOnMainThread() - - // If something went wrong during startup then show the UI still (it has custom UI for - // this case) but don't mark the app as ready or trigger the 'launchDidComplete' logic - guard - migrationsCompleted, - dependencies[singleton: .storage].isValid, - !dependencies[singleton: .appReadiness].isAppReady - else { return showLockScreenOrMainContent() } - - // Note that this does much more than set a flag; - // it will also run all deferred blocks. - dependencies[singleton: .appReadiness].setAppReady() - dependencies.mutate(cache: .appVersion) { $0.saeLaunchDidComplete() } - - showLockScreenOrMainContent() - } override func viewDidLoad() { super.viewDidLoad() @@ -172,22 +161,25 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { // MARK: - Updating - private func showLockScreenOrMainContent() { + private func showLockScreenOrMainContent(hasUserMetadata: Bool) { if dependencies[singleton: .storage, key: .isScreenLockEnabled] { - showLockScreen() + showLockScreen(hasUserMetadata: hasUserMetadata) } else { - showMainContent() + showMainContent(hasUserMetadata: hasUserMetadata) } } - private func showLockScreen() { - let screenLockVC = SAEScreenLockViewController(shareViewDelegate: self) + private func showLockScreen(hasUserMetadata: Bool) { + let screenLockVC = SAEScreenLockViewController( + hasUserMetadata: hasUserMetadata, + shareViewDelegate: self + ) setViewControllers([ screenLockVC ], animated: false) } - private func showMainContent() { - let threadPickerVC: ThreadPickerVC = ThreadPickerVC(using: dependencies) + private func showMainContent(hasUserMetadata: Bool) { + let threadPickerVC: ThreadPickerVC = ThreadPickerVC(hasUserMetadata: hasUserMetadata, using: dependencies) threadPickerVC.shareNavController = self setViewControllers([ threadPickerVC ], animated: false) @@ -208,8 +200,8 @@ final class ShareNavController: UINavigationController, ShareViewDelegate { ShareNavController.attachmentPrepPublisher = publisher } - func shareViewWasUnlocked() { - showMainContent() + func shareViewWasUnlocked(hasUserMetadata: Bool) { + showMainContent(hasUserMetadata: hasUserMetadata) } func shareViewWasCompleted() { diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 67b44dd51d..f65c85b1ab 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -13,7 +13,8 @@ import SessionUtilitiesKit final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableViewDelegate, AttachmentApprovalViewControllerDelegate, ThemedNavigation { private let viewModel: ThreadPickerViewModel - private var dataChangeObservable: DatabaseCancellable? { + private let hasUserMetadata: Bool + private var dataChangeObservable: AnyCancellable? { didSet { oldValue?.cancel() } // Cancel the old observable if there was one } private var hasLoadedInitialData: Bool = false @@ -23,8 +24,9 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView // MARK: - Intialization - init(using dependencies: Dependencies) { - viewModel = ThreadPickerViewModel(using: dependencies) + init(hasUserMetadata: Bool, using dependencies: Dependencies) { + self.viewModel = ThreadPickerViewModel(using: dependencies) + self.hasUserMetadata = hasUserMetadata super.init(nibName: nil, bundle: nil) } @@ -50,18 +52,6 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView return titleLabel }() - private lazy var databaseErrorLabel: UILabel = { - let result: UILabel = UILabel() - result.font = .systemFont(ofSize: Values.mediumFontSize) - result.text = "shareExtensionDatabaseError".localized() - result.textAlignment = .center - result.themeTextColor = .textPrimary - result.numberOfLines = 0 - result.isHidden = true - - return result - }() - private lazy var noAccountErrorLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.mediumFontSize) @@ -71,21 +61,22 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView result.textAlignment = .center result.themeTextColor = .textPrimary result.numberOfLines = 0 - result.isHidden = true + result.isHidden = hasUserMetadata return result }() private lazy var tableView: UITableView = { - let tableView: UITableView = UITableView() - tableView.themeBackgroundColor = .backgroundPrimary - tableView.separatorStyle = .none - tableView.register(view: SimplifiedConversationCell.self) - tableView.showsVerticalScrollIndicator = false - tableView.dataSource = self - tableView.delegate = self + let result: UITableView = UITableView() + result.themeBackgroundColor = .backgroundPrimary + result.separatorStyle = .none + result.register(view: SimplifiedConversationCell.self) + result.showsVerticalScrollIndicator = false + result.dataSource = self + result.delegate = self + result.isHidden = !hasUserMetadata - return tableView + return result }() // MARK: - Lifecycle @@ -98,7 +89,6 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView view.themeBackgroundColor = .backgroundPrimary view.addSubview(tableView) - view.addSubview(databaseErrorLabel) view.addSubview(noAccountErrorLabel) setupLayout() @@ -151,10 +141,6 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func setupLayout() { tableView.pin(to: view) - databaseErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing) - databaseErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing) - databaseErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing) - noAccountErrorLabel.pin(.top, to: .top, of: view, withInset: Values.massiveSpacing) noAccountErrorLabel.pin(.leading, to: .leading, of: view, withInset: Values.veryLargeSpacing) noAccountErrorLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.veryLargeSpacing) @@ -165,47 +151,44 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView private func startObservingChanges() { guard dataChangeObservable == nil else { return } - noAccountErrorLabel.isHidden = viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] - tableView.isHidden = !viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] + tableView.isHidden = !noAccountErrorLabel.isHidden - guard viewModel.dependencies[singleton: .storage, key: .isReadyForAppExtensions] else { return } + guard hasUserMetadata else { return } // Start observing for data changes - dataChangeObservable = self.viewModel.dependencies[singleton: .storage].start( - viewModel.observableViewData, - onError: { [weak self, dependencies = self.viewModel.dependencies] _ in - self?.databaseErrorLabel.isHidden = dependencies[singleton: .storage].isValid - }, - onChange: { [weak self] viewData in - // The defaul scheduler emits changes on the main thread - self?.handleUpdates(viewData) - } - ) + dataChangeObservable = self.viewModel.observableViewData + .subscribe(on: DispatchQueue.global(qos: .userInitiated), using: self.viewModel.dependencies) + .receive(on: DispatchQueue.main, using: self.viewModel.dependencies) + .sink( + receiveCompletion: { _ in }, + receiveValue: { [weak self] updatedData, changeset in + // Ensure the first load runs without animations (if we don't do this the cells + // will animate in from a frame of CGRect.zero) + guard self?.hasLoadedInitialData == true else { + self?.viewModel.updateData(updatedData) + + UIView.performWithoutAnimation { + self?.tableView.reloadData() + } + return + } + + // Reload the table content (animate changes after the first load) + self?.tableView.reload( + using: changeset, + with: .automatic, + interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues + ) { [weak self] updatedData in + self?.viewModel.updateData(updatedData) + } + } + ) } private func stopObservingChanges() { dataChangeObservable = nil } - private func handleUpdates(_ updatedViewData: [SessionThreadViewModel]) { - // Ensure the first load runs without animations (if we don't do this the cells will animate - // in from a frame of CGRect.zero) - guard hasLoadedInitialData else { - hasLoadedInitialData = true - UIView.performWithoutAnimation { handleUpdates(updatedViewData) } - return - } - - // Reload the table content (animate changes after the first load) - tableView.reload( - using: StagedChangeset(source: viewModel.viewData, target: updatedViewData), - with: .automatic, - interrupt: { $0.changeCount > 100 } // Prevent too many changes from causing performance issues - ) { [weak self] updatedData in - self?.viewModel.updateData(updatedData) - } - } - // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/SessionShareExtension/ThreadPickerViewModel.swift b/SessionShareExtension/ThreadPickerViewModel.swift index 94022afe67..729a8b9bef 100644 --- a/SessionShareExtension/ThreadPickerViewModel.swift +++ b/SessionShareExtension/ThreadPickerViewModel.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation +import Combine import GRDB import DifferenceKit import SignalUtilitiesKit @@ -21,55 +22,20 @@ public class ThreadPickerViewModel { /// This value is the current state of the view public private(set) var viewData: [SessionThreadViewModel] = [] - /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise - /// performance https://github.com/groue/GRDB.swift#valueobservation-performance - /// - /// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static - /// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries - /// - /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) - /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own - /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) - /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this - public lazy var observableViewData = ValueObservation - .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in - let userSessionId: SessionId = dependencies[cache: .general].sessionId - - return try SessionThreadViewModel - .shareQuery(userSessionId: userSessionId) - .fetchAll(db) - .map { threadViewModel in - let wasKickedFromGroup: Bool = ( - threadViewModel.threadVariant == .group && - LibSession.wasKickedFromGroup( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) - ) - let groupIsDestroyed: Bool = ( - threadViewModel.threadVariant == .group && - LibSession.groupIsDestroyed( - groupSessionId: SessionId(.group, hex: threadViewModel.threadId), - using: dependencies - ) - ) - - return threadViewModel.populatingPostQueryData( - db, - currentUserBlinded15SessionIdForThisThread: nil, - currentUserBlinded25SessionIdForThisThread: nil, - wasKickedFromGroup: wasKickedFromGroup, - groupIsDestroyed: groupIsDestroyed, - threadCanWrite: threadViewModel.determineInitialCanWriteFlag(using: dependencies), - using: dependencies - ) + public lazy var observableViewData: AnyPublisher<([SessionThreadViewModel], StagedChangeset<[SessionThreadViewModel]>), Never> = dependencies[cache: .libSession].conversations + .map { conversations -> [SessionThreadViewModel] in + conversations + .filter { + $0.threadIsBlocked == false && // Exclude blocked threads + $0.threadCanWrite == true // Exclude unwritable threads } } - .map { [dependencies] threads -> [SessionThreadViewModel] in - threads.filter { $0.threadCanWrite == true } // Exclude unwritable threads - } .removeDuplicates() - .handleEvents(didFail: { Log.error("Observation failed with error: \($0)") }) + .withPrevious([]) + .map { (previous: [SessionThreadViewModel], current: [SessionThreadViewModel]) in + (current, StagedChangeset(source: current, target: previous)) + } + .eraseToAnyPublisher() // MARK: - Functions diff --git a/SessionUtilitiesKit/Database/Storage.swift b/SessionUtilitiesKit/Database/Storage.swift index 6bd11ded3d..a28f07d3f4 100644 --- a/SessionUtilitiesKit/Database/Storage.swift +++ b/SessionUtilitiesKit/Database/Storage.swift @@ -58,10 +58,10 @@ open class Storage { /// take a long time then we should probably be handling it asynchronously rather than a synchronous way) private static let transactionDeadlockTimeoutSeconds: Int = 5 - public static var sharedDatabaseDirectoryPath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/database" } - private static var databasePath: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" } - private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" } - private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" } + public static var databaseDirectoryPath: String { "\(SessionFileManager.documentDirectoryPath)/database" } + private static var databasePath: String { "\(Storage.databaseDirectoryPath)/\(Storage.dbFileName)" } + private static var databasePathShm: String { "\(Storage.databaseDirectoryPath)/\(Storage.dbFileName)-shm" } + private static var databasePathWal: String { "\(Storage.databaseDirectoryPath)/\(Storage.dbFileName)-wal" } private let dependencies: Dependencies fileprivate var dbWriter: DatabaseWriter? @@ -97,19 +97,31 @@ open class Storage { // MARK: - Initialization - public init(customWriter: DatabaseWriter? = nil, using dependencies: Dependencies) { + public static func createInvalid(with error: Error, using dependencies: Dependencies) -> Storage { + let result: Storage = Storage(using: dependencies) + result.isValid = false + result.startupError = error + + return result + } + + private init(using dependencies: Dependencies) { self.dependencies = dependencies + } + + public convenience init(customWriter: DatabaseWriter? = nil, using dependencies: Dependencies) { + self.init(using: dependencies) configureDatabase(customWriter: customWriter) } - public init( + public convenience init( testAccessTo databasePath: String, encryptedKeyPath: String, encryptedKeyPassword: String, using dependencies: Dependencies ) throws { - self.dependencies = dependencies + self.init(using: dependencies) try testAccess( databasePath: databasePath, @@ -121,8 +133,8 @@ open class Storage { private func configureDatabase(customWriter: DatabaseWriter? = nil) { // Create the database directory if needed and ensure it's protection level is set before attempting to // create the database KeySpec or the database itself - try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: Storage.sharedDatabaseDirectoryPath) - try? dependencies[singleton: .fileManager].protectFileOrFolder(at: Storage.sharedDatabaseDirectoryPath) + try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: Storage.databaseDirectoryPath) + try? dependencies[singleton: .fileManager].protectFileOrFolder(at: Storage.databaseDirectoryPath) // If a custom writer was provided then use that (for unit testing) guard customWriter == nil else { @@ -139,7 +151,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.. [FileAttributeKey: Any] @@ -108,6 +110,10 @@ public extension SessionFileManager { return NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true)[0] } + static var documentDirectoryPath: String { + return NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + } + static var nonInjectedAppSharedDataDirectoryPath: String { return (FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: UserDefaults.applicationGroup)? .path) @@ -310,15 +316,23 @@ public class SessionFileManager: FileManagerType { } public func copyItem(atPath: String, toPath: String) throws { - return try fileManager.copyItem(atPath: atPath, toPath: toPath) + try fileManager.copyItem(atPath: atPath, toPath: toPath) } public func copyItem(at fromUrl: URL, to toUrl: URL) throws { - return try fileManager.copyItem(at: fromUrl, to: toUrl) + 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) + try fileManager.removeItem(atPath: atPath) } public func attributesOfItem(atPath path: String) throws -> [FileAttributeKey: Any] { 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.. UserInfo? in guard Identity.userExists(db, using: dependencies), - let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db) - else { return } + let userKeyPair: KeyPair = Identity.fetchUserKeyPair(db), + let userEdKeyPair: KeyPair = Identity.fetchUserEd25519KeyPair(db) + else { return nil } let userSessionId: SessionId = SessionId(.standard, publicKey: userKeyPair.publicKey) @@ -95,6 +107,22 @@ public enum AppSetup { ) cache.loadState(db, requestId: requestId) dependencies.set(cache: .libSession, to: cache) + + return (userSessionId, userEdKeyPair.secretKey) + } + + /// Save the `UserMetadata` and replicate `ConfigDump` data if needed + if let userInfo: UserInfo = maybeUserInfo { + dependencies[singleton: .extensionHelper].saveUserMetadataIfNeeded( + sessionId: userInfo.sessionId, + ed25519SecretKey: userInfo.ed25519SecretKey + ) + + Task { + dependencies[singleton: .extensionHelper].replicateAllConfigDumpsIfNeeded( + userSessionId: userInfo.sessionId + ) + } } /// Ensure any recurring jobs are properly scheduled diff --git a/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift b/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift index 887dabcda8..90ee0a5019 100644 --- a/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift +++ b/SignalUtilitiesKit/Utilities/ShareViewDelegate.swift @@ -6,7 +6,7 @@ import Foundation // All Observer methods will be invoked from the main thread. @objc public protocol ShareViewDelegate: AnyObject { - func shareViewWasUnlocked() + func shareViewWasUnlocked(hasUserMetadata: Bool) func shareViewWasCompleted() func shareViewWasCancelled() func shareViewFailed(error: Error)